diff --git a/.appveyor.yml b/.appveyor.yml index 0f5dea9c5..b0740b1ac 100644 --- a/.appveyor.yml +++ b/.appveyor.yml @@ -6,6 +6,7 @@ init: # Uncomment previous line to get RDP access during the build. environment: + COVERAGE_CORE: sysmon EXECUTABLE: python.exe TEST_OPTIONS: DEPLOY: YES @@ -14,7 +15,7 @@ environment: ARCHITECTURE: x86 APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2022 - PYTHON: C:/Python38-x64 - ARCHITECTURE: x64 + ARCHITECTURE: AMD64 APPVEYOR_BUILD_WORKER_IMAGE: Visual Studio 2017 diff --git a/.ci/requirements-cibw.txt b/.ci/requirements-cibw.txt index dd61634cd..45c2af975 100644 --- a/.ci/requirements-cibw.txt +++ b/.ci/requirements-cibw.txt @@ -1 +1 @@ -cibuildwheel==2.16.2 +cibuildwheel==2.17.0 diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt new file mode 100644 index 000000000..6b0535fc1 --- /dev/null +++ b/.ci/requirements-mypy.txt @@ -0,0 +1 @@ +mypy==1.9.0 diff --git a/.coveragerc b/.coveragerc index f71b6b1a2..018cc1cbf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -2,15 +2,19 @@ [report] # Regexes for lines to exclude from consideration -exclude_lines = - # Have to re-enable the standard pragma: - pragma: no cover - - # Don't complain if non-runnable code isn't run: +exclude_also = + # Don't complain if non-runnable code isn't run if 0: if __name__ == .__main__.: # Don't complain about debug code if DEBUG: + # Don't complain about compatibility code for missing optional dependencies + except ImportError + if TYPE_CHECKING: + @abc.abstractmethod + # Empty bodies in protocols or abstract methods + ^\s*def [a-zA-Z0-9_]+\(.*\)(\s*->.*)?:\s*\.\.\.(\s*#.*)?$ + ^\s*\.\.\.(\s*#.*)?$ [run] omit = diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index e0e6804bf..8fc6bd0ad 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -tidelift: "pypi/Pillow" +tidelift: "pypi/pillow" diff --git a/.github/problem-matchers/gcc.json b/.github/problem-matchers/gcc.json new file mode 100644 index 000000000..8e2866afe --- /dev/null +++ b/.github/problem-matchers/gcc.json @@ -0,0 +1,18 @@ +{ + "__comment": "Based on vscode-cpptools' Extension/package.json gcc rule", + "problemMatcher": [ + { + "owner": "gcc-problem-matcher", + "pattern": [ + { + "regexp": "^\\s*(.*):(\\d+):(\\d+):\\s+(?:fatal\\s+)?(warning|error):\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5 + } + ] + } + ] +} diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml index 4d855469a..3711d91f0 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -13,6 +13,8 @@ categories: label: "Removal" - title: "Testing" label: "Testing" + - title: "Type hints" + label: "Type hints" exclude-labels: - "changelog: skip" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 9fe345c8a..92e860cb5 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,10 +7,12 @@ on: paths: - ".github/workflows/docs.yml" - "docs/**" + - "src/PIL/**" pull_request: paths: - ".github/workflows/docs.yml" - "docs/**" + - "src/PIL/**" workflow_dispatch: permissions: @@ -37,16 +39,26 @@ jobs: with: python-version: "3.x" cache: pip - cache-dependency-path: ".ci/*.sh" + cache-dependency-path: | + ".ci/*.sh" + "pyproject.toml" - name: Build system information run: python3 .github/workflows/system-info.py + - name: Cache libimagequant + uses: actions/cache@v4 + id: cache-libimagequant + with: + path: ~/cache-libimagequant + key: ${{ runner.os }}-libimagequant-${{ hashFiles('depends/install_imagequant.sh') }} + - name: Install Linux dependencies run: | .ci/install.sh env: GHA_PYTHON_VERSION: "3.x" + GHA_LIBIMAGEQUANT_CACHE_HIT: ${{ steps.cache-libimagequant.outputs.cache-hit }} - name: Build run: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9069fc615..cc4760288 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/checkout@v4 - name: pre-commit cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.cache/pre-commit key: lint-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }} diff --git a/.github/workflows/macos-install.sh b/.github/workflows/macos-install.sh index f41324c4b..28124d7f7 100755 --- a/.github/workflows/macos-install.sh +++ b/.github/workflows/macos-install.sh @@ -2,7 +2,16 @@ set -e -brew install libtiff libjpeg openjpeg libimagequant webp little-cms2 freetype libraqm +brew install \ + freetype \ + ghostscript \ + libimagequant \ + libjpeg \ + libraqm \ + libtiff \ + little-cms2 \ + openjpeg \ + webp export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" # TODO Update condition when cffi supports 3.13 diff --git a/.github/workflows/release-drafter.yml b/.github/workflows/release-drafter.yml index 8fc7bd379..a8ddef22c 100644 --- a/.github/workflows/release-drafter.yml +++ b/.github/workflows/release-drafter.yml @@ -23,6 +23,6 @@ jobs: runs-on: ubuntu-latest steps: # Drafts your next release notes as pull requests are merged into "main" - - uses: release-drafter/release-drafter@v5 + - uses: release-drafter/release-drafter@v6 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/system-info.py b/.github/workflows/system-info.py index 57f28c620..9e97b8971 100644 --- a/.github/workflows/system-info.py +++ b/.github/workflows/system-info.py @@ -6,6 +6,7 @@ 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 diff --git a/.github/workflows/test-cygwin.yml b/.github/workflows/test-cygwin.yml index 32ac6f65e..4526b9454 100644 --- a/.github/workflows/test-cygwin.yml +++ b/.github/workflows/test-cygwin.yml @@ -8,7 +8,6 @@ on: - ".github/workflows/docs.yml" - ".github/workflows/wheels*" - ".gitmodules" - - ".travis.yml" - "docs/**" - "wheels/**" pull_request: @@ -16,7 +15,6 @@ on: - ".github/workflows/docs.yml" - ".github/workflows/wheels*" - ".gitmodules" - - ".travis.yml" - "docs/**" - "wheels/**" workflow_dispatch: @@ -28,6 +26,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + COVERAGE_CORE: sysmon + jobs: build: runs-on: windows-latest @@ -49,9 +50,8 @@ jobs: uses: actions/checkout@v4 - name: Install Cygwin - uses: cygwin/cygwin-install-action@v4 + uses: egor-tensin/setup-cygwin@v4 with: - platform: x86_64 packages: > gcc-g++ ghostscript @@ -71,6 +71,7 @@ jobs: make netpbm perl + python39=3.9.16-1 python3${{ matrix.python-minor-version }}-cffi python3${{ matrix.python-minor-version }}-cython python3${{ matrix.python-minor-version }}-devel @@ -82,13 +83,13 @@ jobs: zlib-devel - name: Add Lapack to PATH - uses: egor-tensin/cleanup-path@v3 + uses: egor-tensin/cleanup-path@v4 with: dirs: 'C:\cygwin\bin;C:\cygwin\lib\lapack' - name: Select Python version run: | - ln -sf c:/cygwin/bin/python3.${{ matrix.python-minor-version }} c:/cygwin/bin/python3 + ln -sf c:/tools/cygwin/bin/python3.${{ matrix.python-minor-version }} c:/tools/cygwin/bin/python3 - name: Get latest NumPy version id: latest-numpy @@ -97,7 +98,7 @@ jobs: 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 + uses: actions/cache@v4 with: path: 'C:\cygwin\home\runneradmin\.cache\pip' key: ${{ runner.os }}-cygwin-pip3.${{ matrix.python-minor-version }}-numpy${{ steps.latest-numpy.outputs.version }}-${{ hashFiles('.ci/install.sh') }} @@ -143,7 +144,7 @@ jobs: bash.exe .ci/after_success.sh - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v3.1.5 with: file: ./coverage.xml flags: GHA_Cygwin diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index eb27b4bf7..f40286fe4 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -8,7 +8,6 @@ on: - ".github/workflows/docs.yml" - ".github/workflows/wheels*" - ".gitmodules" - - ".travis.yml" - "docs/**" - "wheels/**" pull_request: @@ -16,7 +15,6 @@ on: - ".github/workflows/docs.yml" - ".github/workflows/wheels*" - ".gitmodules" - - ".travis.yml" - "docs/**" - "wheels/**" workflow_dispatch: @@ -103,7 +101,7 @@ jobs: MATRIX_DOCKER: ${{ matrix.docker }} - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v3.1.5 with: flags: GHA_Docker name: ${{ matrix.docker }} diff --git a/.github/workflows/test-mingw.yml b/.github/workflows/test-mingw.yml index 115c2e9be..a07a27c46 100644 --- a/.github/workflows/test-mingw.yml +++ b/.github/workflows/test-mingw.yml @@ -8,7 +8,6 @@ on: - ".github/workflows/docs.yml" - ".github/workflows/wheels*" - ".gitmodules" - - ".travis.yml" - "docs/**" - "wheels/**" pull_request: @@ -16,7 +15,6 @@ on: - ".github/workflows/docs.yml" - ".github/workflows/wheels*" - ".gitmodules" - - ".travis.yml" - "docs/**" - "wheels/**" workflow_dispatch: @@ -28,6 +26,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + COVERAGE_CORE: sysmon + jobs: build: runs-on: windows-latest @@ -66,10 +67,10 @@ jobs: 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 ensurepip python3 -m pip install pyroma pytest pytest-cov pytest-timeout pushd depends && ./install_extra_test_images.sh && popd @@ -84,7 +85,7 @@ jobs: python3 -m pytest -vx --cov PIL --cov Tests --cov-report term --cov-report xml Tests - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v3.1.5 with: file: ./coverage.xml flags: GHA_Windows diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 86cd5b5fa..c936be559 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -2,11 +2,12 @@ name: Test Windows on: push: + branches: + - "**" paths-ignore: - ".github/workflows/docs.yml" - ".github/workflows/wheels*" - ".gitmodules" - - ".travis.yml" - "docs/**" - "wheels/**" pull_request: @@ -14,7 +15,6 @@ on: - ".github/workflows/docs.yml" - ".github/workflows/wheels*" - ".gitmodules" - - ".travis.yml" - "docs/**" - "wheels/**" workflow_dispatch: @@ -26,13 +26,16 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + COVERAGE_CORE: sysmon + jobs: build: runs-on: windows-latest strategy: fail-fast: false matrix: - python-version: ["pypy3.10", "pypy3.9", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["pypy3.10", "pypy3.9", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13.0-alpha.3"] timeout-minutes: 30 @@ -66,8 +69,16 @@ jobs: - name: Print build system information run: python3 .github/workflows/system-info.py - - 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 Python dependencies + run: > + python3 -m pip install + coverage>=7.4.2 + defusedxml + olefile + pyroma + pytest + pytest-cov + pytest-timeout - name: Install dependencies id: install @@ -89,7 +100,7 @@ jobs: - name: Cache build id: build-cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: winbuild\build key: @@ -202,7 +213,7 @@ jobs: shell: pwsh - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v3.1.5 with: file: ./coverage.xml flags: GHA_Windows diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa0e25138..643273e58 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,6 @@ on: - ".github/workflows/docs.yml" - ".github/workflows/wheels*" - ".gitmodules" - - ".travis.yml" - "docs/**" - "wheels/**" pull_request: @@ -16,7 +15,6 @@ on: - ".github/workflows/docs.yml" - ".github/workflows/wheels*" - ".gitmodules" - - ".travis.yml" - "docs/**" - "wheels/**" workflow_dispatch: @@ -28,6 +26,10 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + COVERAGE_CORE: sysmon + FORCE_COLOR: 1 + jobs: build: @@ -35,7 +37,7 @@ jobs: fail-fast: false matrix: os: [ - "macos-latest", + "macos-14", "ubuntu-latest", ] python-version: [ @@ -49,11 +51,21 @@ jobs: "3.8", ] include: - - python-version: "3.9" + - python-version: "3.11" PYTHONOPTIMIZE: 1 REVERSE: "--reverse" - - python-version: "3.8" + - python-version: "3.10" PYTHONOPTIMIZE: 2 + # M1 only available for 3.10+ + - os: "macos-latest" + python-version: "3.9" + - os: "macos-latest" + python-version: "3.8" + exclude: + - os: "macos-14" + python-version: "3.9" + - os: "macos-14" + python-version: "3.8" runs-on: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} @@ -67,17 +79,28 @@ jobs: python-version: ${{ matrix.python-version }} allow-prereleases: true cache: pip - cache-dependency-path: ".ci/*.sh" + cache-dependency-path: | + ".ci/*.sh" + "pyproject.toml" - name: Build system information run: python3 .github/workflows/system-info.py + - name: Cache libimagequant + if: startsWith(matrix.os, 'ubuntu') + uses: actions/cache@v4 + id: cache-libimagequant + with: + path: ~/cache-libimagequant + key: ${{ runner.os }}-libimagequant-${{ hashFiles('depends/install_imagequant.sh') }} + - name: Install Linux dependencies if: startsWith(matrix.os, 'ubuntu') run: | .ci/install.sh env: GHA_PYTHON_VERSION: ${{ matrix.python-version }} + GHA_LIBIMAGEQUANT_CACHE_HIT: ${{ steps.cache-libimagequant.outputs.cache-hit }} - name: Install macOS dependencies if: startsWith(matrix.os, 'macOS') @@ -86,6 +109,10 @@ jobs: env: GHA_PYTHON_VERSION: ${{ matrix.python-version }} + - name: Register gcc problem matcher + if: "matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'" + run: echo "::add-matcher::.github/problem-matchers/gcc.json" + - name: Build run: | .ci/build.sh @@ -123,9 +150,9 @@ jobs: .ci/after_success.sh - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v3.1.5 with: - flags: ${{ matrix.os == 'macos-latest' && 'GHA_macOS' || 'GHA_Ubuntu' }} + flags: ${{ matrix.os == 'ubuntu-latest' && 'GHA_Ubuntu' || 'GHA_macOS' }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} gcov: true diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index 3ec314873..e22808ed7 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -17,30 +17,30 @@ 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 +LIBPNG_VERSION=1.6.43 +JPEGTURBO_VERSION=3.0.2 +OPENJPEG_VERSION=2.5.2 XZ_VERSION=5.4.5 TIFF_VERSION=4.6.0 LCMS2_VERSION=2.16 if [[ -n "$IS_MACOS" ]]; then - GIFLIB_VERSION=5.1.4 + GIFLIB_VERSION=5.2.2 else GIFLIB_VERSION=5.2.1 fi if [[ -n "$IS_MACOS" ]] || [[ "$MB_ML_VER" != 2014 ]]; then - ZLIB_VERSION=1.3 + ZLIB_VERSION=1.3.1 else ZLIB_VERSION=1.2.8 fi LIBWEBP_VERSION=1.3.2 BZIP2_VERSION=1.0.8 -LIBXCB_VERSION=1.16 +LIBXCB_VERSION=1.16.1 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) + local out_dir=$(fetch_unpack https://github.com/uclouvain/openjpeg/archive/v${OPENJPEG_VERSION}.tar.gz openjpeg-${OPENJPEG_VERSION}.tar.gz) (cd $out_dir \ && cmake -DCMAKE_INSTALL_PREFIX=$BUILD_PREFIX -DCMAKE_INSTALL_NAME_DIR=$BUILD_PREFIX/lib . \ && make install) @@ -62,7 +62,7 @@ function build_brotli { function build { if [[ -n "$IS_MACOS" ]] && [[ "$CIBW_ARCHS" == "arm64" ]]; then - export BUILD_PREFIX="/usr/local" + sudo chown -R runner /usr/local fi build_xz if [ -z "$IS_ALPINE" ] && [ -z "$IS_MACOS" ]; then @@ -72,13 +72,11 @@ function build { build_simple xcb-proto 1.16.0 https://xorg.freedesktop.org/archive/individual/proto if [ -n "$IS_MACOS" ]; 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 [[ "$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 + cp /usr/local/share/pkgconfig/xcb-proto.pc /usr/local/lib/pkgconfig fi else sed s/\${pc_sysrootdir\}// /usr/local/share/pkgconfig/xcb-proto.pc > /usr/local/lib/pkgconfig/xcb-proto.pc @@ -89,12 +87,10 @@ function build { build_tiff build_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 + if [ -f /usr/local/lib64/libopenjp2.so ]; then + cp /usr/local/lib64/libopenjp2.so /usr/local/lib + fi ORIGINAL_CFLAGS=$CFLAGS CFLAGS="$CFLAGS -O3 -DNDEBUG" @@ -130,14 +126,19 @@ curl -fsSL -o pillow-depends-main.zip https://github.com/python-pillow/pillow-de untar pillow-depends-main.zip 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 + # libtiff and libxcb cause a conflict with building libtiff and libxcb + # libxau and libxdmcp cause an issue on macOS < 11 # 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 + # remove jpeg-turbo to avoid inclusion on arm64 + # remove webp and zstd to avoid inclusion on x86_64 # curl from brew requires zstd, use system curl - brew remove --ignore-dependencies webp libpng libtiff libxcb libxdmcp curl php cairo lcms2 ghostscript zstd + brew remove --ignore-dependencies libpng libtiff libxcb libxau libxdmcp curl cairo lcms2 zstd + if [[ "$CIBW_ARCHS" == "arm64" ]]; then + brew remove --ignore-dependencies jpeg-turbo + else + brew remove --ignore-dependencies webp + fi brew install pkg-config fi diff --git a/.github/workflows/wheels-test.sh b/.github/workflows/wheels-test.sh index 207ec1567..3fbf3be69 100755 --- a/.github/workflows/wheels-test.sh +++ b/.github/workflows/wheels-test.sh @@ -4,6 +4,9 @@ set -e if [[ "$OSTYPE" == "darwin"* ]]; then brew install fribidi export PKG_CONFIG_PATH="/usr/local/opt/openblas/lib/pkgconfig" + if [ -f /opt/homebrew/lib/libfribidi.dylib ]; then + sudo cp /opt/homebrew/lib/libfribidi.dylib /usr/local/lib + fi elif [ "${AUDITWHEEL_POLICY::9}" == "musllinux" ]; then apk add curl fribidi else diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 060fc497e..e4008489b 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -30,7 +30,64 @@ env: FORCE_COLOR: 1 jobs: - build: + build-1-QEMU-emulated-wheels: + name: aarch64 ${{ matrix.python-version }} ${{ matrix.spec }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: + - pp39 + - pp310 + - cp38 + - cp39 + - cp310 + - cp311 + - cp312 + spec: + - manylinux2014 + - manylinux_2_28 + - musllinux + exclude: + - { python-version: pp39, spec: musllinux } + - { python-version: pp310, spec: musllinux } + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + # https://github.com/docker/setup-qemu-action + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Install cibuildwheel + run: | + python3 -m pip install -r .ci/requirements-cibw.txt + + - name: Build wheels + run: | + python3 -m cibuildwheel --output-dir wheelhouse + env: + # Build only the currently selected Linux architecture (so we can + # parallelise for speed). + CIBW_ARCHS: "aarch64" + # Likewise, select only one Python version per job to speed this up. + CIBW_BUILD: "${{ matrix.python-version }}-${{ matrix.spec == 'musllinux' && 'musllinux' || 'manylinux' }}*" + # Extra options for manylinux. + CIBW_MANYLINUX_AARCH64_IMAGE: ${{ matrix.spec }} + CIBW_MANYLINUX_PYPY_AARCH64_IMAGE: ${{ matrix.spec }} + + - uses: actions/upload-artifact@v4 + with: + name: dist-qemu-${{ matrix.python-version }}-${{ matrix.spec }} + path: ./wheelhouse/*.whl + + build-2-native-wheels: name: ${{ matrix.name }} runs-on: ${{ matrix.os }} strategy: @@ -39,18 +96,18 @@ jobs: include: - name: "macOS x86_64" os: macos-latest - archs: x86_64 + cibw_arch: x86_64 macosx_deployment_target: "10.10" - name: "macOS arm64" - os: macos-latest - archs: arm64 + os: macos-14 + cibw_arch: arm64 macosx_deployment_target: "11.0" - name: "manylinux2014 and musllinux x86_64" os: ubuntu-latest - archs: x86_64 + cibw_arch: x86_64 - name: "manylinux_2_28 x86_64" os: ubuntu-latest - archs: x86_64 + cibw_arch: x86_64 build: "*manylinux*" manylinux: "manylinux_2_28" steps: @@ -62,37 +119,37 @@ jobs: with: python-version: "3.x" - - name: Build wheels + - name: Install cibuildwheel run: | python3 -m pip install -r .ci/requirements-cibw.txt + + - name: Build wheels + run: | python3 -m cibuildwheel --output-dir wheelhouse env: - CIBW_ARCHS: ${{ matrix.archs }} + CIBW_ARCHS: ${{ matrix.cibw_arch }} 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" + CIBW_TEST_SKIP: cp38-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) }} + name: dist-${{ matrix.os }}-${{ matrix.cibw_arch }}${{ matrix.manylinux && format('-{0}', matrix.manylinux) }} path: ./wheelhouse/*.whl windows: - name: Windows ${{ matrix.arch }} + name: Windows ${{ matrix.cibw_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 + - cibw_arch: x86 + - cibw_arch: AMD64 + - cibw_arch: ARM64 steps: - uses: actions/checkout@v4 @@ -106,6 +163,10 @@ jobs: with: python-version: "3.x" + - name: Install cibuildwheel + run: | + python.exe -m pip install -r .ci/requirements-cibw.txt + - name: Prepare for build run: | choco install nasm --no-progress @@ -114,9 +175,7 @@ jobs: # 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 }} + & python.exe winbuild\build_prepare.py -v --no-imagequant --architecture=${{ matrix.cibw_arch }} shell: pwsh - name: Build wheels @@ -143,6 +202,7 @@ jobs: CIBW_ARCHS: ${{ matrix.cibw_arch }} CIBW_BEFORE_ALL: "{package}\\winbuild\\build\\build_dep_all.cmd" CIBW_CACHE_PATH: "C:\\cibw" + CIBW_SKIP: pp38-* CIBW_TEST_SKIP: "*-win_arm64" CIBW_TEST_COMMAND: 'docker run --rm -v {project}:C:\pillow @@ -156,13 +216,13 @@ jobs: - name: Upload wheels uses: actions/upload-artifact@v4 with: - name: dist-windows-${{ matrix.arch }} + name: dist-windows-${{ matrix.cibw_arch }} path: ./wheelhouse/*.whl - name: Upload fribidi.dll uses: actions/upload-artifact@v4 with: - name: fribidi-windows-${{ matrix.arch }} + name: fribidi-windows-${{ matrix.cibw_arch }} path: winbuild\build\bin\fribidi* sdist: @@ -186,7 +246,7 @@ jobs: pypi-publish: if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') - needs: [build, windows, sdist] + needs: [build-1-QEMU-emulated-wheels, build-2-native-wheels, windows, sdist] runs-on: ubuntu-latest name: Upload release to PyPI environment: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d1c4b8015..c52fdcb55 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,17 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.1.7 + rev: v0.2.0 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.12.0 + rev: 24.1.1 hooks: - id: black - repo: https://github.com/PyCQA/bandit - rev: 1.7.6 + rev: 1.7.7 hooks: - id: bandit args: [--severity-level=high] @@ -32,6 +32,7 @@ repos: rev: v4.5.0 hooks: - id: check-executables-have-shebangs + - id: check-shebang-scripts-are-executable - id: check-merge-conflict - id: check-json - id: check-toml @@ -47,12 +48,12 @@ repos: - id: sphinx-lint - repo: https://github.com/tox-dev/pyproject-fmt - rev: 1.5.3 + rev: 1.7.0 hooks: - id: pyproject-fmt - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.15 + rev: v0.16 hooks: - id: validate-pyproject diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 8f8250809..000000000 --- a/.travis.yml +++ /dev/null @@ -1,52 +0,0 @@ -if: tag IS present OR type = api - -env: - global: - - CIBW_ARCHS=aarch64 - - CIBW_SKIP=pp38-* - -language: python -# Default Python version is usually 3.6 -python: "3.12" -dist: jammy -services: docker - -jobs: - include: - - name: "manylinux2014 aarch64" - os: linux - arch: arm64 - env: - - CIBW_BUILD="*manylinux*" - - CIBW_MANYLINUX_AARCH64_IMAGE=manylinux2014 - - CIBW_MANYLINUX_PYPY_AARCH64_IMAGE=manylinux2014 - - name: "manylinux_2_28 aarch64" - os: linux - arch: arm64 - env: - - CIBW_BUILD="*manylinux*" - - CIBW_MANYLINUX_AARCH64_IMAGE=manylinux_2_28 - - CIBW_MANYLINUX_PYPY_AARCH64_IMAGE=manylinux_2_28 - - name: "musllinux aarch64" - os: linux - arch: arm64 - env: - - CIBW_BUILD="*musllinux*" - -install: - - python3 -m pip install -r .ci/requirements-cibw.txt - -script: - - python3 -m cibuildwheel --output-dir wheelhouse - - ls -l "${TRAVIS_BUILD_DIR}/wheelhouse/" - -# Upload wheels to GitHub Releases -deploy: - provider: releases - api_key: $GITHUB_RELEASE_TOKEN - file_glob: true - file: "${TRAVIS_BUILD_DIR}/wheelhouse/*.whl" - on: - repo: python-pillow/Pillow - tags: true - skip_cleanup: true diff --git a/CHANGES.rst b/CHANGES.rst index df4e11e0e..20da811ed 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -2,9 +2,102 @@ Changelog (Pillow) ================== -10.2.0 (unreleased) +10.3.0 (unreleased) ------------------- +- Turn off nullability warnings for macOS SDK #7827 + [radarhere] + +- Fix shift-sign issue in Convert.c #7838 + [r-barnes, radarhere] + +- Open 16-bit grayscale PNGs as I;16 #7849 + [radarhere] + +- Handle truncated chunks at the end of PNG images #7709 + [lajiyuan, radarhere] + +- Match mask size to pasted image size in GifImagePlugin #7779 + [radarhere] + +- Release GIL while calling ``WebPAnimDecoderGetNext`` #7782 + [evanmiller, radarhere] + +- Fixed reading FLI/FLC images with a prefix chunk #7804 + [twolife] + +- Update wl-paste handling and return None for some errors in grabclipboard() on Linux #7745 + [nik012003, radarhere] + +- Remove execute bit from ``setup.py`` #7760 + [hugovk] + +- Do not support using test-image-results to upload images after test failures #7739 + [radarhere] + +- Changed ImageMath.ops to be static #7721 + [radarhere] + +- Fix APNG info after seeking backwards more than twice #7701 + [esoma, radarhere] + +- Deprecate ImageCms constants and versions() function #7702 + [nulano, radarhere] + +- Added PerspectiveTransform #7699 + [radarhere] + +- Add support for reading and writing grayscale PFM images #7696 + [nulano, hugovk] + +- Add LCMS2 flags to ImageCms #7676 + [nulano, radarhere, hugovk] + +- Rename x64 to AMD64 in winbuild #7693 + [nulano] + +10.2.0 (2024-01-02) +------------------- + +- Add ``keep_rgb`` option when saving JPEG to prevent conversion of RGB colorspace #7553 + [bgilbert, radarhere] + +- Trim glyph size in ImageFont.getmask() #7669, #7672 + [radarhere, nulano] + +- Deprecate IptcImagePlugin helpers #7664 + [nulano, hugovk, radarhere] + +- Allow uncompressed TIFF images to be saved in chunks #7650 + [radarhere] + +- Concatenate multiple JPEG EXIF markers #7496 + [radarhere] + +- Changed IPTC tile tuple to match other plugins #7661 + [radarhere] + +- Do not assign new fp attribute when exiting context manager #7566 + [radarhere] + +- Support arbitrary masks for uncompressed RGB DDS images #7589 + [radarhere, akx] + +- Support setting ROWSPERSTRIP tag #7654 + [radarhere] + +- Apply ImageFont.MAX_STRING_LENGTH to ImageFont.getmask() #7662 + [radarhere] + +- Optimise ``ImageColor`` using ``functools.lru_cache`` #7657 + [hugovk] + +- Restricted environment keys for ImageMath.eval() #7655 + [wiredfool, radarhere] + +- Optimise ``ImageMode.getmode`` using ``functools.lru_cache`` #7641 + [hugovk, radarhere] + - Fix incorrect color blending for overlapping glyphs #7497 [ZachNagengast, nulano, radarhere] diff --git a/LICENSE b/LICENSE index cf65e86d7..0069eb5bc 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-2023 by Jeffrey A. Clark (Alex) and contributors. + Copyright © 2010-2024 by Jeffrey A. Clark (Alex) and contributors. Like PIL, Pillow is licensed under the open source HPND License: diff --git a/README.md b/README.md index e11bd2faa..f142ef563 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,6 @@ As of 2019, Pillow development is GitHub Actions build status (Wheels) - Travis CI wheels build status (aarch64) Code coverage @@ -67,11 +64,11 @@ As of 2019, Pillow development is src="https://zenodo.org/badge/17549/python-pillow/Pillow.svg"> Tidelift - + Newest PyPI version - Number of PyPI downloads Join the chat at https://gitter.im/python-pillow/Pillow - Follow on https://twitter.com/PythonPillow Follow on https://fosstodon.org/@pillow None: (w, h) = size for x in range(w): for y in range(h): access[(x, y)] -def iterate_set(size, access): +def iterate_set(size, access) -> None: (w, h) = size for x in range(w): for y in range(h): access[(x, y)] = (x % 256, y % 256, 0) -def timer(func, label, *args): +def timer(func, label, *args) -> None: iterations = 5000 starttime = time.time() for x in range(iterations): @@ -37,7 +38,7 @@ def timer(func, label, *args): ) -def test_direct(): +def test_direct() -> None: im = hopper() im.load() # im = Image.new("RGB", (2000, 2000), (1, 3, 2)) diff --git a/Tests/check_fli_oob.py b/Tests/check_fli_oob.py index ac46ff1eb..e0057a2c2 100644 --- a/Tests/check_fli_oob.py +++ b/Tests/check_fli_oob.py @@ -1,4 +1,3 @@ -#!/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 0fabcb5d3..5c89efc76 100644 --- a/Tests/check_fli_overflow.py +++ b/Tests/check_fli_overflow.py @@ -1,10 +1,11 @@ from __future__ import annotations + from PIL import Image TEST_FILE = "Tests/images/fli_overflow.fli" -def test_fli_overflow(): +def test_fli_overflow() -> None: # this should not crash with a malloc error or access violation with Image.open(TEST_FILE) as im: im.load() diff --git a/Tests/check_imaging_leaks.py b/Tests/check_imaging_leaks.py index 8c17c051d..231789ca0 100755 --- a/Tests/check_imaging_leaks.py +++ b/Tests/check_imaging_leaks.py @@ -1,5 +1,8 @@ #!/usr/bin/env python3 from __future__ import annotations + +from typing import Any, Callable + import pytest from PIL import Image @@ -12,31 +15,37 @@ max_iterations = 10000 pytestmark = pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") -def _get_mem_usage(): +def _get_mem_usage() -> float: from resource import RUSAGE_SELF, getpagesize, getrusage mem = getrusage(RUSAGE_SELF).ru_maxrss return mem * getpagesize() / 1024 / 1024 -def _test_leak(min_iterations, max_iterations, fn, *args, **kwargs): +def _test_leak( + min_iterations: int, + max_iterations: int, + fn: Callable[..., Image.Image | None], + *args: Any, +) -> None: mem_limit = None for i in range(max_iterations): - fn(*args, **kwargs) + fn(*args) mem = _get_mem_usage() if i < min_iterations: mem_limit = mem + 1 continue msg = f"memory usage limit exceeded after {i + 1} iterations" + assert mem_limit is not None assert mem <= mem_limit, msg -def test_leak_putdata(): +def test_leak_putdata() -> None: im = Image.new("RGB", (25, 25)) _test_leak(min_iterations, max_iterations, im.putdata, im.getdata()) -def test_leak_getlist(): +def test_leak_getlist() -> None: im = Image.new("P", (25, 25)) _test_leak( min_iterations, diff --git a/Tests/check_j2k_leaks.py b/Tests/check_j2k_leaks.py index 83a12e2c2..bbe35b591 100644 --- a/Tests/check_j2k_leaks.py +++ b/Tests/check_j2k_leaks.py @@ -1,4 +1,5 @@ from __future__ import annotations + from io import BytesIO import pytest @@ -19,7 +20,7 @@ pytestmark = [ ] -def test_leak_load(): +def test_leak_load() -> None: from resource import RLIMIT_AS, RLIMIT_STACK, setrlimit setrlimit(RLIMIT_STACK, (stack_size, stack_size)) @@ -29,7 +30,7 @@ def test_leak_load(): im.load() -def test_leak_save(): +def test_leak_save() -> None: from resource import RLIMIT_AS, RLIMIT_STACK, setrlimit setrlimit(RLIMIT_STACK, (stack_size, stack_size)) diff --git a/Tests/check_j2k_overflow.py b/Tests/check_j2k_overflow.py index 982f6ea74..dbdd5a4f5 100644 --- a/Tests/check_j2k_overflow.py +++ b/Tests/check_j2k_overflow.py @@ -1,10 +1,13 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import Image -def test_j2k_overflow(tmp_path): +def test_j2k_overflow(tmp_path: Path) -> None: im = Image.new("RGBA", (1024, 131584)) target = str(tmp_path / "temp.jpc") with pytest.raises(OSError): diff --git a/Tests/check_jp2_overflow.py b/Tests/check_jp2_overflow.py old mode 100755 new mode 100644 index 9afbff112..954d68bf7 --- a/Tests/check_jp2_overflow.py +++ b/Tests/check_jp2_overflow.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python3 - # Reproductions/tests for OOB read errors in FliDecode.c # When run in python, all of these images should fail for @@ -14,7 +12,6 @@ # version. from __future__ import annotations - from PIL import Image repro = ("00r0_gray_l.jp2", "00r1_graya_la.jp2") diff --git a/Tests/check_jpeg_leaks.py b/Tests/check_jpeg_leaks.py index 3cd37c7af..5f290c6cd 100644 --- a/Tests/check_jpeg_leaks.py +++ b/Tests/check_jpeg_leaks.py @@ -1,4 +1,5 @@ from __future__ import annotations + from io import BytesIO import pytest @@ -110,14 +111,14 @@ standard_chrominance_qtable = ( [standard_l_qtable, standard_chrominance_qtable], ), ) -def test_qtables_leak(qtables): +def test_qtables_leak(qtables: tuple[tuple[int, ...]] | list[tuple[int, ...]]) -> None: im = hopper("RGB") for _ in range(iterations): test_output = BytesIO() im.save(test_output, "JPEG", qtables=qtables) -def test_exif_leak(): +def test_exif_leak() -> None: """ pre patch: @@ -180,7 +181,7 @@ def test_exif_leak(): im.save(test_output, "JPEG", exif=exif) -def test_base_save(): +def test_base_save() -> None: """ base case: MB diff --git a/Tests/check_large_memory.py b/Tests/check_large_memory.py index 9b83798d5..a9ce79e57 100644 --- a/Tests/check_large_memory.py +++ b/Tests/check_large_memory.py @@ -1,5 +1,8 @@ from __future__ import annotations + import sys +from pathlib import Path +from types import ModuleType import pytest @@ -15,6 +18,7 @@ from PIL import Image # 2.7 and 3.2. +numpy: ModuleType | None try: import numpy except ImportError: @@ -27,23 +31,24 @@ XDIM = 48000 pytestmark = pytest.mark.skipif(sys.maxsize <= 2**32, reason="requires 64-bit system") -def _write_png(tmp_path, xdim, ydim): +def _write_png(tmp_path: Path, xdim: int, ydim: int) -> None: f = str(tmp_path / "temp.png") im = Image.new("L", (xdim, ydim), 0) im.save(f) -def test_large(tmp_path): +def test_large(tmp_path: Path) -> None: """succeeded prepatch""" _write_png(tmp_path, XDIM, YDIM) -def test_2gpx(tmp_path): +def test_2gpx(tmp_path: Path) -> None: """failed prepatch""" _write_png(tmp_path, XDIM, XDIM) @pytest.mark.skipif(numpy is None, reason="Numpy is not installed") -def test_size_greater_than_int(): +def test_size_greater_than_int() -> None: + assert numpy is not None arr = numpy.ndarray(shape=(16394, 16394)) Image.fromarray(arr) diff --git a/Tests/check_large_memory_numpy.py b/Tests/check_large_memory_numpy.py index 0ff3de8dc..f4ca8d0aa 100644 --- a/Tests/check_large_memory_numpy.py +++ b/Tests/check_large_memory_numpy.py @@ -1,5 +1,7 @@ from __future__ import annotations + import sys +from pathlib import Path import pytest @@ -23,7 +25,7 @@ XDIM = 48000 pytestmark = pytest.mark.skipif(sys.maxsize <= 2**32, reason="requires 64-bit system") -def _write_png(tmp_path, xdim, ydim): +def _write_png(tmp_path: Path, xdim: int, ydim: int) -> None: dtype = np.uint8 a = np.zeros((xdim, ydim), dtype=dtype) f = str(tmp_path / "temp.png") @@ -31,11 +33,11 @@ def _write_png(tmp_path, xdim, ydim): im.save(f) -def test_large(tmp_path): +def test_large(tmp_path: Path) -> None: """succeeded prepatch""" _write_png(tmp_path, XDIM, YDIM) -def test_2gpx(tmp_path): +def test_2gpx(tmp_path: Path) -> None: """failed prepatch""" _write_png(tmp_path, XDIM, XDIM) diff --git a/Tests/check_libtiff_segfault.py b/Tests/check_libtiff_segfault.py index ee1d7d11f..84bda53ed 100644 --- a/Tests/check_libtiff_segfault.py +++ b/Tests/check_libtiff_segfault.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image @@ -6,7 +7,7 @@ from PIL import Image TEST_FILE = "Tests/images/libtiff_segfault.tif" -def test_libtiff_segfault(): +def test_libtiff_segfault() -> None: """This test should not segfault. It will on Pillow <= 3.1.0 and libtiff >= 4.0.0 """ diff --git a/Tests/check_png_dos.py b/Tests/check_png_dos.py index 292fe4b7f..63d6657bc 100644 --- a/Tests/check_png_dos.py +++ b/Tests/check_png_dos.py @@ -1,4 +1,5 @@ from __future__ import annotations + import zlib from io import BytesIO @@ -7,7 +8,7 @@ from PIL import Image, ImageFile, PngImagePlugin TEST_FILE = "Tests/images/png_decompression_dos.png" -def test_ignore_dos_text(): +def test_ignore_dos_text() -> None: ImageFile.LOAD_TRUNCATED_IMAGES = True try: @@ -16,6 +17,7 @@ def test_ignore_dos_text(): finally: ImageFile.LOAD_TRUNCATED_IMAGES = False + assert isinstance(im, PngImagePlugin.PngImageFile) for s in im.text.values(): assert len(s) < 1024 * 1024, "Text chunk larger than 1M" @@ -23,7 +25,7 @@ def test_ignore_dos_text(): assert len(s) < 1024 * 1024, "Text chunk larger than 1M" -def test_dos_text(): +def test_dos_text() -> None: try: im = Image.open(TEST_FILE) im.load() @@ -31,11 +33,12 @@ def test_dos_text(): assert msg, "Decompressed Data Too Large" return + assert isinstance(im, PngImagePlugin.PngImageFile) for s in im.text.values(): assert len(s) < 1024 * 1024, "Text chunk larger than 1M" -def test_dos_total_memory(): +def test_dos_total_memory() -> None: im = Image.new("L", (1, 1)) compressed_data = zlib.compress(b"a" * 1024 * 1023) @@ -52,10 +55,11 @@ def test_dos_total_memory(): try: im2 = Image.open(b) except ValueError as msg: - assert "Too much memory" in msg + assert "Too much memory" in str(msg) return total_len = 0 + assert isinstance(im2, PngImagePlugin.PngImageFile) for txt in im2.text.values(): total_len += len(txt) assert total_len < 64 * 1024 * 1024, "Total text chunks greater than 64M" diff --git a/Tests/check_release_notes.py b/Tests/check_release_notes.py index ebfaffa47..cf414d7ff 100644 --- a/Tests/check_release_notes.py +++ b/Tests/check_release_notes.py @@ -1,4 +1,5 @@ from __future__ import annotations + import sys from pathlib import Path diff --git a/Tests/check_wheel.py b/Tests/check_wheel.py index afe4cc3ee..4b91984f5 100644 --- a/Tests/check_wheel.py +++ b/Tests/check_wheel.py @@ -1,10 +1,11 @@ from __future__ import annotations + import sys from PIL import features -def test_wheel_modules(): +def test_wheel_modules() -> None: expected_modules = {"pil", "tkinter", "freetype2", "littlecms2", "webp"} # tkinter is not available in cibuildwheel installed CPython on Windows @@ -18,13 +19,13 @@ def test_wheel_modules(): assert set(features.get_supported_modules()) == expected_modules -def test_wheel_codecs(): +def test_wheel_codecs() -> None: expected_codecs = {"jpg", "jpg_2000", "zlib", "libtiff"} assert set(features.get_supported_codecs()) == expected_codecs -def test_wheel_features(): +def test_wheel_features() -> None: expected_features = { "webp_anim", "webp_mux", diff --git a/Tests/conftest.py b/Tests/conftest.py index cd64bd755..e00d1f019 100644 --- a/Tests/conftest.py +++ b/Tests/conftest.py @@ -1,8 +1,11 @@ from __future__ import annotations + import io +import pytest -def pytest_report_header(config): + +def pytest_report_header(config: pytest.Config) -> str: try: from PIL import features @@ -13,7 +16,7 @@ def pytest_report_header(config): return f"pytest_report_header failed: {e}" -def pytest_configure(config): +def pytest_configure(config: pytest.Config) -> None: config.addinivalue_line( "markers", "pil_noop_mark: A conditional mark where nothing special happens", diff --git a/Tests/createfontdatachunk.py b/Tests/createfontdatachunk.py index 2e990b709..41c76f87e 100755 --- a/Tests/createfontdatachunk.py +++ b/Tests/createfontdatachunk.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 from __future__ import annotations + import base64 import os diff --git a/Tests/helper.py b/Tests/helper.py index b333c2fd4..9849bf655 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -1,6 +1,7 @@ """ Helper functions. """ + from __future__ import annotations import logging @@ -11,6 +12,7 @@ import sys import sysconfig import tempfile from io import BytesIO +from typing import Any, Callable, Sequence import pytest from packaging.version import parse as parse_version @@ -19,42 +21,31 @@ from PIL import Image, ImageMath, features logger = logging.getLogger(__name__) - -HAS_UPLOADER = False - +uploader = None if os.environ.get("SHOW_ERRORS"): - # local img.show for errors. - HAS_UPLOADER = True - - class test_image_results: - @staticmethod - def upload(a, b): - a.show() - b.show() - + uploader = "show" elif "GITHUB_ACTIONS" in os.environ: - HAS_UPLOADER = True - - class test_image_results: - @staticmethod - def upload(a, b): - dir_errors = os.path.join(os.path.dirname(__file__), "errors") - os.makedirs(dir_errors, exist_ok=True) - tmpdir = tempfile.mkdtemp(dir=dir_errors) - a.save(os.path.join(tmpdir, "a.png")) - b.save(os.path.join(tmpdir, "b.png")) - return tmpdir - -else: - try: - import test_image_results - - HAS_UPLOADER = True - except ImportError: - pass + uploader = "github_actions" -def convert_to_comparable(a, b): +def upload(a: Image.Image, b: Image.Image) -> str | None: + if uploader == "show": + # local img.show for errors. + a.show() + b.show() + elif uploader == "github_actions": + dir_errors = os.path.join(os.path.dirname(__file__), "errors") + os.makedirs(dir_errors, exist_ok=True) + tmpdir = tempfile.mkdtemp(dir=dir_errors) + a.save(os.path.join(tmpdir, "a.png")) + b.save(os.path.join(tmpdir, "b.png")) + return tmpdir + return None + + +def convert_to_comparable( + a: Image.Image, b: Image.Image +) -> tuple[Image.Image, Image.Image]: new_a, new_b = a, b if a.mode == "P": new_a = Image.new("L", a.size) @@ -67,14 +58,18 @@ def convert_to_comparable(a, b): return new_a, new_b -def assert_deep_equal(a, b, msg=None): +def assert_deep_equal( + a: Sequence[Any], b: Sequence[Any], msg: str | None = None +) -> None: try: assert len(a) == len(b), msg or f"got length {len(a)}, expected {len(b)}" except Exception: assert a == b, msg -def assert_image(im, mode, size, msg=None): +def assert_image( + im: Image.Image, mode: str, size: tuple[int, int], msg: str | None = None +) -> None: if mode is not None: assert im.mode == mode, ( msg or f"got mode {repr(im.mode)}, expected {repr(mode)}" @@ -86,28 +81,32 @@ def assert_image(im, mode, size, msg=None): ) -def assert_image_equal(a, b, msg=None): +def assert_image_equal(a: Image.Image, b: Image.Image, msg: str | None = None) -> None: assert a.mode == b.mode, msg or f"got mode {repr(a.mode)}, expected {repr(b.mode)}" assert a.size == b.size, msg or f"got size {repr(a.size)}, expected {repr(b.size)}" if a.tobytes() != b.tobytes(): - if HAS_UPLOADER: - try: - url = test_image_results.upload(a, b) + try: + url = upload(a, b) + if url: logger.error("URL for test images: %s", url) - except Exception: - pass + except Exception: + pass pytest.fail(msg or "got different content") -def assert_image_equal_tofile(a, filename, msg=None, mode=None): +def assert_image_equal_tofile( + a: Image.Image, filename: str, msg: str | None = None, mode: str | None = None +) -> None: with Image.open(filename) as img: if mode: img = img.convert(mode) assert_image_equal(a, img, msg) -def assert_image_similar(a, b, epsilon, msg=None): +def assert_image_similar( + a: Image.Image, b: Image.Image, epsilon: float, msg: str | None = None +) -> None: assert a.mode == b.mode, msg or f"got mode {repr(a.mode)}, expected {repr(b.mode)}" assert a.size == b.size, msg or f"got size {repr(a.size)}, expected {repr(b.size)}" @@ -125,55 +124,68 @@ def assert_image_similar(a, b, epsilon, msg=None): + f" average pixel value difference {ave_diff:.4f} > epsilon {epsilon:.4f}" ) except Exception as e: - if HAS_UPLOADER: - try: - url = test_image_results.upload(a, b) + try: + url = upload(a, b) + if url: logger.exception("URL for test images: %s", url) - except Exception: - pass + except Exception: + pass raise e -def assert_image_similar_tofile(a, filename, epsilon, msg=None, mode=None): +def assert_image_similar_tofile( + a: Image.Image, + filename: str, + epsilon: float, + msg: str | None = None, + mode: str | None = None, +) -> None: with Image.open(filename) as img: if mode: img = img.convert(mode) assert_image_similar(a, img, epsilon, msg) -def assert_all_same(items, msg=None): +def assert_all_same(items: Sequence[Any], msg: str | None = None) -> None: assert items.count(items[0]) == len(items), msg -def assert_not_all_same(items, msg=None): +def assert_not_all_same(items: Sequence[Any], msg: str | None = None) -> None: assert items.count(items[0]) != len(items), msg -def assert_tuple_approx_equal(actuals, targets, threshold, msg): +def assert_tuple_approx_equal( + actuals: Sequence[int], targets: tuple[int, ...], threshold: int, msg: str +) -> None: """Tests if actuals has values within threshold from targets""" - value = True for i, target in enumerate(targets): - value *= target - threshold <= actuals[i] <= target + threshold - - assert value, msg + ": " + repr(actuals) + " != " + repr(targets) + if not (target - threshold <= actuals[i] <= target + threshold): + pytest.fail(msg + ": " + repr(actuals) + " != " + repr(targets)) -def skip_unless_feature(feature): +def skip_unless_feature(feature: str) -> pytest.MarkDecorator: reason = f"{feature} not available" return pytest.mark.skipif(not features.check(feature), reason=reason) -def skip_unless_feature_version(feature, version_required, reason=None): +def skip_unless_feature_version( + feature: str, required: str, reason: str | None = None +) -> pytest.MarkDecorator: if not features.check(feature): return pytest.mark.skip(f"{feature} not available") if reason is None: - reason = f"{feature} is older than {version_required}" - version_required = parse_version(version_required) + reason = f"{feature} is older than {required}" + version_required = parse_version(required) version_available = parse_version(features.version(feature)) return pytest.mark.skipif(version_available < version_required, reason=reason) -def mark_if_feature_version(mark, feature, version_blacklist, reason=None): +def mark_if_feature_version( + mark: pytest.MarkDecorator, + feature: str, + version_blacklist: str, + reason: str | None = None, +) -> pytest.MarkDecorator: if not features.check(feature): return pytest.mark.pil_noop_mark() if reason is None: @@ -194,7 +206,7 @@ class PillowLeakTestCase: iterations = 100 # count mem_limit = 512 # k - def _get_mem_usage(self): + def _get_mem_usage(self) -> float: """ Gets the RUSAGE memory usage, returns in K. Encapsulates the difference between macOS and Linux rss reporting @@ -216,7 +228,7 @@ class PillowLeakTestCase: # This is the maximum resident set size used (in kilobytes). return mem # Kb - def _test_leak(self, core): + def _test_leak(self, core: Callable[[], None]) -> None: start_mem = self._get_mem_usage() for cycle in range(self.iterations): core() @@ -228,17 +240,17 @@ class PillowLeakTestCase: # helpers -def fromstring(data): +def fromstring(data: bytes) -> Image.Image: return Image.open(BytesIO(data)) -def tostring(im, string_format, **options): +def tostring(im: Image.Image, string_format: str, **options: Any) -> bytes: out = BytesIO() im.save(out, string_format, **options) return out.getvalue() -def hopper(mode=None, cache={}): +def hopper(mode: str | None = None, cache: dict[str, Image.Image] = {}) -> Image.Image: if mode is None: # Always return fresh not-yet-loaded version of image. # Operations on not-yet-loaded images is separate class of errors @@ -259,29 +271,31 @@ def hopper(mode=None, cache={}): return im.copy() -def djpeg_available(): +def djpeg_available() -> bool: if shutil.which("djpeg"): try: subprocess.check_call(["djpeg", "-version"]) return True except subprocess.CalledProcessError: # pragma: no cover return False + return False -def cjpeg_available(): +def cjpeg_available() -> bool: if shutil.which("cjpeg"): try: subprocess.check_call(["cjpeg", "-version"]) return True except subprocess.CalledProcessError: # pragma: no cover return False + return False -def netpbm_available(): +def netpbm_available() -> bool: return bool(shutil.which("ppmquant") and shutil.which("ppmtogif")) -def magick_command(): +def magick_command() -> list[str] | None: if sys.platform == "win32": magickhome = os.environ.get("MAGICK_HOME") if magickhome: @@ -298,47 +312,48 @@ def magick_command(): return imagemagick if graphicsmagick and shutil.which(graphicsmagick[0]): return graphicsmagick + return None -def on_appveyor(): +def on_appveyor() -> bool: return "APPVEYOR" in os.environ -def on_github_actions(): +def on_github_actions() -> bool: return "GITHUB_ACTIONS" in os.environ -def on_ci(): +def on_ci() -> bool: # GitHub Actions and AppVeyor have "CI" return "CI" in os.environ -def is_big_endian(): +def is_big_endian() -> bool: return sys.byteorder == "big" -def is_ppc64le(): +def is_ppc64le() -> bool: import platform return platform.machine() == "ppc64le" -def is_win32(): +def is_win32() -> bool: return sys.platform.startswith("win32") -def is_pypy(): +def is_pypy() -> bool: return hasattr(sys, "pypy_translation_info") -def is_mingw(): +def is_mingw() -> bool: return sysconfig.get_platform() == "mingw" class CachedProperty: - def __init__(self, func): + def __init__(self, func: Callable[[Any], Any]) -> None: self.func = func - def __get__(self, instance, cls=None): + def __get__(self, instance: Any, cls: type[Any] | None = None) -> Any: result = instance.__dict__[self.func.__name__] = self.func(instance) return result diff --git a/Tests/images/16_bit_binary_pgm.png b/Tests/images/16_bit_binary_pgm.png deleted file mode 100644 index 918be1ad4..000000000 Binary files a/Tests/images/16_bit_binary_pgm.png and /dev/null differ diff --git a/Tests/images/16_bit_binary_pgm.tiff b/Tests/images/16_bit_binary_pgm.tiff new file mode 100644 index 000000000..1ce808bcf Binary files /dev/null and b/Tests/images/16_bit_binary_pgm.tiff differ diff --git a/Tests/images/2422.flc b/Tests/images/2422.flc new file mode 100644 index 000000000..eed5fb59e Binary files /dev/null and b/Tests/images/2422.flc differ diff --git a/Tests/images/apng/different_durations.png b/Tests/images/apng/different_durations.png new file mode 100644 index 000000000..984254b8e Binary files /dev/null and b/Tests/images/apng/different_durations.png differ diff --git a/Tests/images/bgr15.dds b/Tests/images/bgr15.dds new file mode 100644 index 000000000..ba3bbddca Binary files /dev/null and b/Tests/images/bgr15.dds differ diff --git a/Tests/images/bgr15.png b/Tests/images/bgr15.png new file mode 100644 index 000000000..a15ab5ad2 Binary files /dev/null and b/Tests/images/bgr15.png differ diff --git a/Tests/images/cmx3g8_wv_1998.260_0745_mcidas.png b/Tests/images/cmx3g8_wv_1998.260_0745_mcidas.png deleted file mode 100644 index 2b84283b7..000000000 Binary files a/Tests/images/cmx3g8_wv_1998.260_0745_mcidas.png and /dev/null differ diff --git a/Tests/images/cmx3g8_wv_1998.260_0745_mcidas.tiff b/Tests/images/cmx3g8_wv_1998.260_0745_mcidas.tiff new file mode 100644 index 000000000..b72509cc4 Binary files /dev/null and b/Tests/images/cmx3g8_wv_1998.260_0745_mcidas.tiff differ diff --git a/Tests/images/create_eps.gnuplot b/Tests/images/create_eps.gnuplot index 4d7e29877..57a3c8c97 100644 --- a/Tests/images/create_eps.gnuplot +++ b/Tests/images/create_eps.gnuplot @@ -1,5 +1,3 @@ -#!/usr/bin/gnuplot - #This is the script that was used to create our sample EPS files #We used the following version of the gnuplot program #G N U P L O T diff --git a/Tests/images/hopper.pfm b/Tests/images/hopper.pfm new file mode 100644 index 000000000..b57661564 Binary files /dev/null and b/Tests/images/hopper.pfm differ diff --git a/Tests/images/hopper_be.pfm b/Tests/images/hopper_be.pfm new file mode 100644 index 000000000..93c75e26f Binary files /dev/null and b/Tests/images/hopper_be.pfm differ diff --git a/Tests/images/hopper_emboss.bmp b/Tests/images/hopper_emboss.bmp index d8e001e2b..01d48fa3f 100644 Binary files a/Tests/images/hopper_emboss.bmp and b/Tests/images/hopper_emboss.bmp differ diff --git a/Tests/images/hopper_emboss_I.png b/Tests/images/hopper_emboss_I.png deleted file mode 100644 index f4dab388f..000000000 Binary files a/Tests/images/hopper_emboss_I.png and /dev/null differ diff --git a/Tests/images/hopper_emboss_more.bmp b/Tests/images/hopper_emboss_more.bmp index 37a5db830..01247f97e 100644 Binary files a/Tests/images/hopper_emboss_more.bmp and b/Tests/images/hopper_emboss_more.bmp differ diff --git a/Tests/images/hopper_emboss_more_I.png b/Tests/images/hopper_emboss_more_I.png deleted file mode 100644 index c417c915f..000000000 Binary files a/Tests/images/hopper_emboss_more_I.png and /dev/null differ diff --git a/Tests/images/imagedraw_rectangle_I.png b/Tests/images/imagedraw_rectangle_I.png deleted file mode 100644 index a75f12c2e..000000000 Binary files a/Tests/images/imagedraw_rectangle_I.png and /dev/null differ diff --git a/Tests/images/imagedraw_rectangle_I.tiff b/Tests/images/imagedraw_rectangle_I.tiff new file mode 100644 index 000000000..9b9eda883 Binary files /dev/null and b/Tests/images/imagedraw_rectangle_I.tiff differ diff --git a/Tests/images/multiple_exif.jpg b/Tests/images/multiple_exif.jpg new file mode 100644 index 000000000..32e0aa301 Binary files /dev/null and b/Tests/images/multiple_exif.jpg differ diff --git a/Tests/images/negative_top_left_layer.psd b/Tests/images/negative_top_left_layer.psd new file mode 100644 index 000000000..be9d9d6d0 Binary files /dev/null and b/Tests/images/negative_top_left_layer.psd differ diff --git a/Tests/images/p_8.tga b/Tests/images/p_8.tga new file mode 100644 index 000000000..73759a282 Binary files /dev/null and b/Tests/images/p_8.tga differ diff --git a/Tests/images/truncated_end_chunk.png b/Tests/images/truncated_end_chunk.png new file mode 100644 index 000000000..5e88c5e4f Binary files /dev/null and b/Tests/images/truncated_end_chunk.png differ diff --git a/Tests/images/unknown_compression_method.png b/Tests/images/unknown_compression_method.png new file mode 100644 index 000000000..e1614a731 Binary files /dev/null and b/Tests/images/unknown_compression_method.png differ diff --git a/Tests/images/unsupported_bitcount_luminance.dds b/Tests/images/unsupported_bitcount.dds similarity index 100% rename from Tests/images/unsupported_bitcount_luminance.dds rename to Tests/images/unsupported_bitcount.dds diff --git a/Tests/images/unsupported_bitcount_rgb.dds b/Tests/images/unsupported_bitcount_rgb.dds deleted file mode 100644 index 77d527507..000000000 Binary files a/Tests/images/unsupported_bitcount_rgb.dds and /dev/null differ diff --git a/Tests/oss-fuzz/fuzz_font.py b/Tests/oss-fuzz/fuzz_font.py index 024117c56..8788d7021 100755 --- a/Tests/oss-fuzz/fuzz_font.py +++ b/Tests/oss-fuzz/fuzz_font.py @@ -1,7 +1,5 @@ #!/usr/bin/python3 -from __future__ import annotations - # Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -25,7 +23,7 @@ with atheris.instrument_imports(): import fuzzers -def TestOneInput(data): +def TestOneInput(data: bytes) -> None: try: fuzzers.fuzz_font(data) except Exception: @@ -34,7 +32,7 @@ def TestOneInput(data): pass -def main(): +def main() -> None: fuzzers.enable_decompressionbomb_error() atheris.Setup(sys.argv, TestOneInput) atheris.Fuzz() diff --git a/Tests/oss-fuzz/fuzz_pillow.py b/Tests/oss-fuzz/fuzz_pillow.py index c1ab42e56..9137391b6 100644 --- a/Tests/oss-fuzz/fuzz_pillow.py +++ b/Tests/oss-fuzz/fuzz_pillow.py @@ -1,7 +1,3 @@ -#!/usr/bin/python3 - -from __future__ import annotations - # Copyright 2020 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -25,7 +21,7 @@ with atheris.instrument_imports(): import fuzzers -def TestOneInput(data): +def TestOneInput(data: bytes) -> None: try: fuzzers.fuzz_image(data) except Exception: @@ -34,7 +30,7 @@ def TestOneInput(data): pass -def main(): +def main() -> None: fuzzers.enable_decompressionbomb_error() atheris.Setup(sys.argv, TestOneInput) atheris.Fuzz() diff --git a/Tests/oss-fuzz/fuzzers.py b/Tests/oss-fuzz/fuzzers.py index 3f3c1e388..d6c1fab71 100644 --- a/Tests/oss-fuzz/fuzzers.py +++ b/Tests/oss-fuzz/fuzzers.py @@ -1,22 +1,23 @@ from __future__ import annotations + import io import warnings from PIL import Image, ImageDraw, ImageFile, ImageFilter, ImageFont -def enable_decompressionbomb_error(): +def enable_decompressionbomb_error() -> None: ImageFile.LOAD_TRUNCATED_IMAGES = True warnings.filterwarnings("ignore") warnings.simplefilter("error", Image.DecompressionBombWarning) -def disable_decompressionbomb_error(): +def disable_decompressionbomb_error() -> None: ImageFile.LOAD_TRUNCATED_IMAGES = False warnings.resetwarnings() -def fuzz_image(data): +def fuzz_image(data: bytes) -> None: # This will fail on some images in the corpus, as we have many # invalid images in the test suite. with Image.open(io.BytesIO(data)) as im: @@ -25,7 +26,7 @@ def fuzz_image(data): im.save(io.BytesIO(), "BMP") -def fuzz_font(data): +def fuzz_font(data: bytes) -> None: wrapper = io.BytesIO(data) try: font = ImageFont.truetype(wrapper) diff --git a/Tests/oss-fuzz/test_fuzzers.py b/Tests/oss-fuzz/test_fuzzers.py index 186a0efd3..58d0213e8 100644 --- a/Tests/oss-fuzz/test_fuzzers.py +++ b/Tests/oss-fuzz/test_fuzzers.py @@ -1,4 +1,5 @@ from __future__ import annotations + import subprocess import sys @@ -23,7 +24,7 @@ if features.check("libjpeg_turbo"): "path", subprocess.check_output("find Tests/images -type f", shell=True).split(b"\n"), ) -def test_fuzz_images(path): +def test_fuzz_images(path: str) -> None: fuzzers.enable_decompressionbomb_error() try: with open(path, "rb") as f: @@ -54,7 +55,7 @@ def test_fuzz_images(path): @pytest.mark.parametrize( "path", subprocess.check_output("find Tests/fonts -type f", shell=True).split(b"\n") ) -def test_fuzz_fonts(path): +def test_fuzz_fonts(path: str) -> None: if not path: return with open(path, "rb") as f: diff --git a/Tests/test_000_sanity.py b/Tests/test_000_sanity.py index c582dfad3..c3926250f 100644 --- a/Tests/test_000_sanity.py +++ b/Tests/test_000_sanity.py @@ -1,8 +1,9 @@ from __future__ import annotations + from PIL import Image -def test_sanity(): +def test_sanity() -> None: # Make sure we have the binary extension Image.core.new("L", (100, 100)) diff --git a/Tests/test_binary.py b/Tests/test_binary.py index 62da26636..d19799a09 100644 --- a/Tests/test_binary.py +++ b/Tests/test_binary.py @@ -1,13 +1,14 @@ from __future__ import annotations + from PIL import _binary -def test_standard(): +def test_standard() -> None: assert _binary.i8(b"*") == 42 assert _binary.o8(42) == b"*" -def test_little_endian(): +def test_little_endian() -> None: assert _binary.i16le(b"\xff\xff\x00\x00") == 65535 assert _binary.i32le(b"\xff\xff\x00\x00") == 65535 @@ -15,7 +16,7 @@ def test_little_endian(): assert _binary.o32le(65535) == b"\xff\xff\x00\x00" -def test_big_endian(): +def test_big_endian() -> None: assert _binary.i16be(b"\x00\x00\xff\xff") == 0 assert _binary.i32be(b"\x00\x00\xff\xff") == 65535 diff --git a/Tests/test_bmp_reference.py b/Tests/test_bmp_reference.py index bed8dc3a8..0ad496135 100644 --- a/Tests/test_bmp_reference.py +++ b/Tests/test_bmp_reference.py @@ -1,4 +1,5 @@ from __future__ import annotations + import os import warnings @@ -9,13 +10,13 @@ from .helper import assert_image_similar base = os.path.join("Tests", "images", "bmp") -def get_files(d, ext=".bmp"): +def get_files(d: str, ext: str = ".bmp") -> list[str]: return [ os.path.join(base, d, f) for f in os.listdir(os.path.join(base, d)) if ext in f ] -def test_bad(): +def test_bad() -> None: """These shouldn't crash/dos, but they shouldn't return anything either""" for f in get_files("b"): @@ -28,7 +29,7 @@ def test_bad(): pass -def test_questionable(): +def test_questionable() -> None: """These shouldn't crash/dos, but it's not well defined that these are in spec""" supported = [ @@ -55,7 +56,7 @@ def test_questionable(): raise -def test_good(): +def test_good() -> None: """These should all work. There's a set of target files in the html directory that we can compare against.""" @@ -79,7 +80,7 @@ def test_good(): "rgb32bf.bmp": "rgb24.png", } - def get_compare(f): + def get_compare(f: str) -> str: name = os.path.split(f)[1] if name in file_map: return os.path.join(base, "html", file_map[name]) diff --git a/Tests/test_box_blur.py b/Tests/test_box_blur.py index e798cba3d..1f6ed6127 100644 --- a/Tests/test_box_blur.py +++ b/Tests/test_box_blur.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, ImageFilter @@ -15,18 +16,18 @@ sample.putdata(sum([ # fmt: on -def test_imageops_box_blur(): +def test_imageops_box_blur() -> None: i = sample.filter(ImageFilter.BoxBlur(1)) assert i.mode == sample.mode assert i.size == sample.size assert isinstance(i, Image.Image) -def box_blur(image, radius=1, n=1): +def box_blur(image: Image.Image, radius: float = 1, n: int = 1) -> Image.Image: return image._new(image.im.box_blur((radius, radius), n)) -def assert_image(im, data, delta=0): +def assert_image(im: Image.Image, data: list[list[int]], delta: int = 0) -> None: it = iter(im.getdata()) for data_row in data: im_row = [next(it) for _ in range(im.size[0])] @@ -36,7 +37,13 @@ def assert_image(im, data, delta=0): next(it) -def assert_blur(im, radius, data, passes=1, delta=0): +def assert_blur( + im: Image.Image, + radius: float, + data: list[list[int]], + passes: int = 1, + delta: int = 0, +) -> None: # check grayscale image assert_image(box_blur(im, radius, passes), data, delta) rgba = Image.merge("RGBA", (im, im, im, im)) @@ -44,7 +51,7 @@ def assert_blur(im, radius, data, passes=1, delta=0): assert_image(band, data, delta) -def test_color_modes(): +def test_color_modes() -> None: with pytest.raises(ValueError): box_blur(sample.convert("1")) with pytest.raises(ValueError): @@ -64,7 +71,7 @@ def test_color_modes(): box_blur(sample.convert("YCbCr")) -def test_radius_0(): +def test_radius_0() -> None: assert_blur( sample, 0, @@ -80,7 +87,7 @@ def test_radius_0(): ) -def test_radius_0_02(): +def test_radius_0_02() -> None: assert_blur( sample, 0.02, @@ -97,7 +104,7 @@ def test_radius_0_02(): ) -def test_radius_0_05(): +def test_radius_0_05() -> None: assert_blur( sample, 0.05, @@ -114,7 +121,7 @@ def test_radius_0_05(): ) -def test_radius_0_1(): +def test_radius_0_1() -> None: assert_blur( sample, 0.1, @@ -131,7 +138,7 @@ def test_radius_0_1(): ) -def test_radius_0_5(): +def test_radius_0_5() -> None: assert_blur( sample, 0.5, @@ -148,7 +155,7 @@ def test_radius_0_5(): ) -def test_radius_1(): +def test_radius_1() -> None: assert_blur( sample, 1, @@ -165,7 +172,7 @@ def test_radius_1(): ) -def test_radius_1_5(): +def test_radius_1_5() -> None: assert_blur( sample, 1.5, @@ -182,7 +189,7 @@ def test_radius_1_5(): ) -def test_radius_bigger_then_half(): +def test_radius_bigger_then_half() -> None: assert_blur( sample, 3, @@ -199,7 +206,7 @@ def test_radius_bigger_then_half(): ) -def test_radius_bigger_then_width(): +def test_radius_bigger_then_width() -> None: assert_blur( sample, 10, @@ -214,7 +221,7 @@ def test_radius_bigger_then_width(): ) -def test_extreme_large_radius(): +def test_extreme_large_radius() -> None: assert_blur( sample, 600, @@ -229,7 +236,7 @@ def test_extreme_large_radius(): ) -def test_two_passes(): +def test_two_passes() -> None: assert_blur( sample, 1, @@ -247,7 +254,7 @@ def test_two_passes(): ) -def test_three_passes(): +def test_three_passes() -> None: assert_blur( sample, 1, diff --git a/Tests/test_color_lut.py b/Tests/test_color_lut.py index 448ba2fac..c8886a779 100644 --- a/Tests/test_color_lut.py +++ b/Tests/test_color_lut.py @@ -1,5 +1,7 @@ from __future__ import annotations + from array import array +from types import ModuleType import pytest @@ -7,6 +9,7 @@ from PIL import Image, ImageFilter from .helper import assert_image_equal +numpy: ModuleType | None try: import numpy except ImportError: @@ -14,7 +17,9 @@ except ImportError: class TestColorLut3DCoreAPI: - def generate_identity_table(self, channels, size): + def generate_identity_table( + self, channels: int, size: int | tuple[int, int, int] + ) -> tuple[int, int, int, int, list[float]]: if isinstance(size, tuple): size_1d, size_2d, size_3d = size else: @@ -40,7 +45,7 @@ class TestColorLut3DCoreAPI: [item for sublist in table for item in sublist], ) - def test_wrong_args(self): + def test_wrong_args(self) -> None: im = Image.new("RGB", (10, 10), 0) with pytest.raises(ValueError, match="filter"): @@ -100,7 +105,7 @@ class TestColorLut3DCoreAPI: with pytest.raises(TypeError): im.im.color_lut_3d("RGB", Image.Resampling.BILINEAR, 3, 2, 2, 2, 16) - def test_correct_args(self): + def test_correct_args(self) -> None: im = Image.new("RGB", (10, 10), 0) im.im.color_lut_3d( @@ -135,7 +140,7 @@ class TestColorLut3DCoreAPI: *self.generate_identity_table(3, (3, 3, 65)), ) - def test_wrong_mode(self): + def test_wrong_mode(self) -> None: with pytest.raises(ValueError, match="wrong mode"): im = Image.new("L", (10, 10), 0) im.im.color_lut_3d( @@ -166,7 +171,7 @@ class TestColorLut3DCoreAPI: "RGB", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3) ) - def test_correct_mode(self): + def test_correct_mode(self) -> None: im = Image.new("RGBA", (10, 10), 0) im.im.color_lut_3d( "RGBA", Image.Resampling.BILINEAR, *self.generate_identity_table(3, 3) @@ -187,7 +192,7 @@ class TestColorLut3DCoreAPI: "RGBA", Image.Resampling.BILINEAR, *self.generate_identity_table(4, 3) ) - def test_identities(self): + def test_identities(self) -> None: g = Image.linear_gradient("L") im = Image.merge( "RGB", @@ -223,7 +228,7 @@ class TestColorLut3DCoreAPI: ), ) - def test_identities_4_channels(self): + def test_identities_4_channels(self) -> None: g = Image.linear_gradient("L") im = Image.merge( "RGB", @@ -246,7 +251,7 @@ class TestColorLut3DCoreAPI: ), ) - def test_copy_alpha_channel(self): + def test_copy_alpha_channel(self) -> None: g = Image.linear_gradient("L") im = Image.merge( "RGBA", @@ -269,7 +274,7 @@ class TestColorLut3DCoreAPI: ), ) - def test_channels_order(self): + def test_channels_order(self) -> None: g = Image.linear_gradient("L") im = Image.merge( "RGB", @@ -294,7 +299,7 @@ class TestColorLut3DCoreAPI: ]))) # fmt: on - def test_overflow(self): + def test_overflow(self) -> None: g = Image.linear_gradient("L") im = Image.merge( "RGB", @@ -347,7 +352,7 @@ class TestColorLut3DCoreAPI: class TestColorLut3DFilter: - def test_wrong_args(self): + def test_wrong_args(self) -> None: with pytest.raises(ValueError, match="should be either an integer"): ImageFilter.Color3DLUT("small", [1]) @@ -375,7 +380,7 @@ class TestColorLut3DFilter: with pytest.raises(ValueError, match="Only 3 or 4 output"): ImageFilter.Color3DLUT((2, 2, 2), [[1, 1]] * 8, channels=2) - def test_convert_table(self): + def test_convert_table(self) -> None: lut = ImageFilter.Color3DLUT(2, [0, 1, 2] * 8) assert tuple(lut.size) == (2, 2, 2) assert lut.name == "Color 3D LUT" @@ -393,7 +398,8 @@ class TestColorLut3DFilter: assert lut.table == list(range(4)) * 8 @pytest.mark.skipif(numpy is None, reason="NumPy not installed") - def test_numpy_sources(self): + def test_numpy_sources(self) -> None: + assert numpy is not None table = numpy.ones((5, 6, 7, 3), dtype=numpy.float16) with pytest.raises(ValueError, match="should have either channels"): lut = ImageFilter.Color3DLUT((5, 6, 7), table) @@ -426,7 +432,8 @@ class TestColorLut3DFilter: assert lut.table[0] == 33 @pytest.mark.skipif(numpy is None, reason="NumPy not installed") - def test_numpy_formats(self): + def test_numpy_formats(self) -> None: + assert numpy is not None g = Image.linear_gradient("L") im = Image.merge( "RGB", @@ -465,7 +472,7 @@ class TestColorLut3DFilter: lut.table = numpy.array(lut.table, dtype=numpy.int8) im.filter(lut) - def test_repr(self): + def test_repr(self) -> None: lut = ImageFilter.Color3DLUT(2, [0, 1, 2] * 8) assert repr(lut) == "" @@ -483,7 +490,7 @@ class TestColorLut3DFilter: class TestGenerateColorLut3D: - def test_wrong_channels_count(self): + def test_wrong_channels_count(self) -> None: with pytest.raises(ValueError, match="3 or 4 output channels"): ImageFilter.Color3DLUT.generate( 5, channels=2, callback=lambda r, g, b: (r, g, b) @@ -497,7 +504,7 @@ class TestGenerateColorLut3D: 5, channels=4, callback=lambda r, g, b: (r, g, b) ) - def test_3_channels(self): + def test_3_channels(self) -> None: lut = ImageFilter.Color3DLUT.generate(5, lambda r, g, b: (r, g, b)) assert tuple(lut.size) == (5, 5, 5) assert lut.name == "Color 3D LUT" @@ -507,7 +514,7 @@ class TestGenerateColorLut3D: 1.0, 0.0, 0.0, 0.0, 0.25, 0.0, 0.25, 0.25, 0.0, 0.5, 0.25, 0.0] # fmt: on - def test_4_channels(self): + def test_4_channels(self) -> None: lut = ImageFilter.Color3DLUT.generate( 5, channels=4, callback=lambda r, g, b: (b, r, g, (r + g + b) / 2) ) @@ -520,7 +527,7 @@ class TestGenerateColorLut3D: ] # fmt: on - def test_apply(self): + def test_apply(self) -> None: lut = ImageFilter.Color3DLUT.generate(5, lambda r, g, b: (r, g, b)) g = Image.linear_gradient("L") @@ -536,7 +543,7 @@ class TestGenerateColorLut3D: class TestTransformColorLut3D: - def test_wrong_args(self): + def test_wrong_args(self) -> None: source = ImageFilter.Color3DLUT.generate(5, lambda r, g, b: (r, g, b)) with pytest.raises(ValueError, match="Only 3 or 4 output"): @@ -551,7 +558,7 @@ class TestTransformColorLut3D: with pytest.raises(TypeError): source.transform(lambda r, g, b, a: (r, g, b)) - def test_target_mode(self): + def test_target_mode(self) -> None: source = ImageFilter.Color3DLUT.generate( 2, lambda r, g, b: (r, g, b), target_mode="HSV" ) @@ -562,7 +569,7 @@ class TestTransformColorLut3D: lut = source.transform(lambda r, g, b: (r, g, b), target_mode="RGB") assert lut.mode == "RGB" - def test_3_to_3_channels(self): + def test_3_to_3_channels(self) -> None: source = ImageFilter.Color3DLUT.generate((3, 4, 5), lambda r, g, b: (r, g, b)) lut = source.transform(lambda r, g, b: (r * r, g * g, b * b)) assert tuple(lut.size) == tuple(source.size) @@ -570,7 +577,7 @@ class TestTransformColorLut3D: assert lut.table != source.table assert lut.table[:10] == [0.0, 0.0, 0.0, 0.25, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0] - def test_3_to_4_channels(self): + def test_3_to_4_channels(self) -> None: source = ImageFilter.Color3DLUT.generate((6, 5, 4), lambda r, g, b: (r, g, b)) lut = source.transform(lambda r, g, b: (r * r, g * g, b * b, 1), channels=4) assert tuple(lut.size) == tuple(source.size) @@ -582,7 +589,7 @@ class TestTransformColorLut3D: 0.4**2, 0.0, 0.0, 1, 0.6**2, 0.0, 0.0, 1] # fmt: on - def test_4_to_3_channels(self): + def test_4_to_3_channels(self) -> None: source = ImageFilter.Color3DLUT.generate( (3, 6, 5), lambda r, g, b: (r, g, b, 1), channels=4 ) @@ -598,7 +605,7 @@ class TestTransformColorLut3D: 1.0, 0.96, 1.0, 0.75, 0.96, 1.0, 0.0, 0.96, 1.0] # fmt: on - def test_4_to_4_channels(self): + def test_4_to_4_channels(self) -> None: source = ImageFilter.Color3DLUT.generate( (6, 5, 4), lambda r, g, b: (r, g, b, 1), channels=4 ) @@ -612,7 +619,7 @@ class TestTransformColorLut3D: 0.4**2, 0.0, 0.0, 0.5, 0.6**2, 0.0, 0.0, 0.5] # fmt: on - def test_with_normals_3_channels(self): + def test_with_normals_3_channels(self) -> None: source = ImageFilter.Color3DLUT.generate( (6, 5, 4), lambda r, g, b: (r * r, g * g, b * b) ) @@ -628,7 +635,7 @@ class TestTransformColorLut3D: 0.24, 0.0, 0.0, 0.8 - (0.8**2), 0, 0, 0, 0, 0] # fmt: on - def test_with_normals_4_channels(self): + def test_with_normals_4_channels(self) -> None: source = ImageFilter.Color3DLUT.generate( (3, 6, 5), lambda r, g, b: (r * r, g * g, b * b, 1), channels=4 ) diff --git a/Tests/test_core_resources.py b/Tests/test_core_resources.py index 5275652f6..2c1de8bc3 100644 --- a/Tests/test_core_resources.py +++ b/Tests/test_core_resources.py @@ -1,4 +1,5 @@ from __future__ import annotations + import sys import pytest @@ -8,7 +9,7 @@ from PIL import Image from .helper import is_pypy -def test_get_stats(): +def test_get_stats() -> None: # Create at least one image Image.new("RGB", (10, 10)) @@ -21,7 +22,7 @@ def test_get_stats(): assert "blocks_cached" in stats -def test_reset_stats(): +def test_reset_stats() -> None: Image.core.reset_stats() stats = Image.core.get_stats() @@ -34,19 +35,19 @@ def test_reset_stats(): class TestCoreMemory: - def teardown_method(self): + def teardown_method(self) -> None: # Restore default values Image.core.set_alignment(1) Image.core.set_block_size(1024 * 1024) Image.core.set_blocks_max(0) Image.core.clear_cache() - def test_get_alignment(self): + def test_get_alignment(self) -> None: alignment = Image.core.get_alignment() assert alignment > 0 - def test_set_alignment(self): + def test_set_alignment(self) -> None: for i in [1, 2, 4, 8, 16, 32]: Image.core.set_alignment(i) alignment = Image.core.get_alignment() @@ -62,12 +63,12 @@ class TestCoreMemory: with pytest.raises(ValueError): Image.core.set_alignment(3) - def test_get_block_size(self): + def test_get_block_size(self) -> None: block_size = Image.core.get_block_size() assert block_size >= 4096 - def test_set_block_size(self): + def test_set_block_size(self) -> None: for i in [4096, 2 * 4096, 3 * 4096]: Image.core.set_block_size(i) block_size = Image.core.get_block_size() @@ -83,7 +84,7 @@ class TestCoreMemory: with pytest.raises(ValueError): Image.core.set_block_size(4000) - def test_set_block_size_stats(self): + def test_set_block_size_stats(self) -> None: Image.core.reset_stats() Image.core.set_blocks_max(0) Image.core.set_block_size(4096) @@ -95,12 +96,12 @@ class TestCoreMemory: if not is_pypy(): assert stats["freed_blocks"] >= 64 - def test_get_blocks_max(self): + def test_get_blocks_max(self) -> None: blocks_max = Image.core.get_blocks_max() assert blocks_max >= 0 - def test_set_blocks_max(self): + def test_set_blocks_max(self) -> None: for i in [0, 1, 10]: Image.core.set_blocks_max(i) blocks_max = Image.core.get_blocks_max() @@ -116,7 +117,7 @@ class TestCoreMemory: Image.core.set_blocks_max(2**29) @pytest.mark.skipif(is_pypy(), reason="Images not collected") - def test_set_blocks_max_stats(self): + def test_set_blocks_max_stats(self) -> None: Image.core.reset_stats() Image.core.set_blocks_max(128) Image.core.set_block_size(4096) @@ -131,7 +132,7 @@ class TestCoreMemory: assert stats["blocks_cached"] == 64 @pytest.mark.skipif(is_pypy(), reason="Images not collected") - def test_clear_cache_stats(self): + def test_clear_cache_stats(self) -> None: Image.core.reset_stats() Image.core.clear_cache() Image.core.set_blocks_max(128) @@ -148,7 +149,7 @@ class TestCoreMemory: assert stats["freed_blocks"] >= 48 assert stats["blocks_cached"] == 16 - def test_large_images(self): + def test_large_images(self) -> None: Image.core.reset_stats() Image.core.set_blocks_max(0) Image.core.set_block_size(4096) @@ -165,14 +166,14 @@ class TestCoreMemory: class TestEnvVars: - def teardown_method(self): + def teardown_method(self) -> None: # Restore default values Image.core.set_alignment(1) Image.core.set_block_size(1024 * 1024) Image.core.set_blocks_max(0) Image.core.clear_cache() - def test_units(self): + def test_units(self) -> None: Image._apply_env_variables({"PILLOW_BLOCKS_MAX": "2K"}) assert Image.core.get_blocks_max() == 2 * 1024 Image._apply_env_variables({"PILLOW_BLOCK_SIZE": "2m"}) @@ -186,6 +187,6 @@ class TestEnvVars: {"PILLOW_BLOCKS_MAX": "wat"}, ), ) - def test_warnings(self, var): + def test_warnings(self, var: dict[str, str]) -> None: with pytest.warns(UserWarning): Image._apply_env_variables(var) diff --git a/Tests/test_decompression_bomb.py b/Tests/test_decompression_bomb.py index 391948d40..9c21efa45 100644 --- a/Tests/test_decompression_bomb.py +++ b/Tests/test_decompression_bomb.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image @@ -11,16 +12,16 @@ ORIGINAL_LIMIT = Image.MAX_IMAGE_PIXELS class TestDecompressionBomb: - def teardown_method(self, method): + def teardown_method(self, method) -> None: Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT - def test_no_warning_small_file(self): + def test_no_warning_small_file(self) -> None: # Implicit assert: no warning. # A warning would cause a failure. with Image.open(TEST_FILE): pass - def test_no_warning_no_limit(self): + def test_no_warning_no_limit(self) -> None: # Arrange # Turn limit off Image.MAX_IMAGE_PIXELS = None @@ -32,7 +33,7 @@ class TestDecompressionBomb: with Image.open(TEST_FILE): pass - def test_warning(self): + def test_warning(self) -> None: # Set limit to trigger warning on the test file Image.MAX_IMAGE_PIXELS = 128 * 128 - 1 assert Image.MAX_IMAGE_PIXELS == 128 * 128 - 1 @@ -41,7 +42,7 @@ class TestDecompressionBomb: with Image.open(TEST_FILE): pass - def test_exception(self): + def test_exception(self) -> None: # Set limit to trigger exception on the test file Image.MAX_IMAGE_PIXELS = 64 * 128 - 1 assert Image.MAX_IMAGE_PIXELS == 64 * 128 - 1 @@ -50,22 +51,22 @@ class TestDecompressionBomb: with Image.open(TEST_FILE): pass - def test_exception_ico(self): + def test_exception_ico(self) -> None: with pytest.raises(Image.DecompressionBombError): with Image.open("Tests/images/decompression_bomb.ico"): pass - def test_exception_gif(self): + def test_exception_gif(self) -> None: with pytest.raises(Image.DecompressionBombError): with Image.open("Tests/images/decompression_bomb.gif"): pass - def test_exception_gif_extents(self): + def test_exception_gif_extents(self) -> None: with Image.open("Tests/images/decompression_bomb_extents.gif") as im: with pytest.raises(Image.DecompressionBombError): im.seek(1) - def test_exception_gif_zero_width(self): + def test_exception_gif_zero_width(self) -> None: # Set limit to trigger exception on the test file Image.MAX_IMAGE_PIXELS = 4 * 64 * 128 assert Image.MAX_IMAGE_PIXELS == 4 * 64 * 128 @@ -74,7 +75,7 @@ class TestDecompressionBomb: with Image.open("Tests/images/zero_width.gif"): pass - def test_exception_bmp(self): + def test_exception_bmp(self) -> None: with pytest.raises(Image.DecompressionBombError): with Image.open("Tests/images/bmp/b/reallybig.bmp"): pass @@ -82,15 +83,15 @@ class TestDecompressionBomb: class TestDecompressionCrop: @classmethod - def setup_class(cls): + def setup_class(cls) -> None: width, height = 128, 128 Image.MAX_IMAGE_PIXELS = height * width * 4 - 1 @classmethod - def teardown_class(cls): + def teardown_class(cls) -> None: Image.MAX_IMAGE_PIXELS = ORIGINAL_LIMIT - def test_enlarge_crop(self): + def test_enlarge_crop(self) -> None: # Crops can extend the extents, therefore we should have the # same decompression bomb warnings on them. with hopper() as src: @@ -98,7 +99,7 @@ class TestDecompressionCrop: with pytest.warns(Image.DecompressionBombWarning): src.crop(box) - def test_crop_decompression_checks(self): + def test_crop_decompression_checks(self) -> None: im = Image.new("RGB", (100, 100)) for value in ((-9999, -9999, -9990, -9990), (-999, -999, -990, -990)): diff --git a/Tests/test_deprecate.py b/Tests/test_deprecate.py index d45a6603c..584d8f91d 100644 --- a/Tests/test_deprecate.py +++ b/Tests/test_deprecate.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import _deprecate @@ -19,12 +20,12 @@ from PIL import _deprecate ), ], ) -def test_version(version, expected): +def test_version(version: int | None, expected: str) -> None: with pytest.warns(DeprecationWarning, match=expected): _deprecate.deprecate("Old thing", version, "new thing") -def test_unknown_version(): +def test_unknown_version() -> None: expected = r"Unknown removal version: 12345. Update PIL\._deprecate\?" with pytest.raises(ValueError, match=expected): _deprecate.deprecate("Old thing", 12345, "new thing") @@ -45,13 +46,13 @@ def test_unknown_version(): ), ], ) -def test_old_version(deprecated, plural, expected): +def test_old_version(deprecated: str, plural: bool, expected: str) -> None: expected = r"" with pytest.raises(RuntimeError, match=expected): _deprecate.deprecate(deprecated, 1, plural=plural) -def test_plural(): +def test_plural() -> None: expected = ( r"Old things are deprecated and will be removed in Pillow 11 \(2024-10-15\)\. " r"Use new thing instead\." @@ -60,7 +61,7 @@ def test_plural(): _deprecate.deprecate("Old things", 11, "new thing", plural=True) -def test_replacement_and_action(): +def test_replacement_and_action() -> None: expected = "Use only one of 'replacement' and 'action'" with pytest.raises(ValueError, match=expected): _deprecate.deprecate( @@ -75,7 +76,7 @@ def test_replacement_and_action(): "Upgrade to new thing.", ], ) -def test_action(action): +def test_action(action: str) -> None: expected = ( r"Old thing is deprecated and will be removed in Pillow 11 \(2024-10-15\)\. " r"Upgrade to new thing\." @@ -84,7 +85,7 @@ def test_action(action): _deprecate.deprecate("Old thing", 11, action=action) -def test_no_replacement_or_action(): +def test_no_replacement_or_action() -> None: expected = ( r"Old thing is deprecated and will be removed in Pillow 11 \(2024-10-15\)" ) diff --git a/Tests/test_features.py b/Tests/test_features.py index 8f0e4b418..8d2d198ff 100644 --- a/Tests/test_features.py +++ b/Tests/test_features.py @@ -1,6 +1,8 @@ from __future__ import annotations + import io import re +from typing import Callable import pytest @@ -14,7 +16,7 @@ except ImportError: pass -def test_check(): +def test_check() -> None: # Check the correctness of the convenience function for module in features.modules: assert features.check_module(module) == features.check(module) @@ -24,11 +26,11 @@ def test_check(): assert features.check_feature(feature) == features.check(feature) -def test_version(): +def test_version() -> None: # Check the correctness of the convenience function # and the format of version numbers - def test(name, function): + def test(name: str, function: Callable[[str], bool]) -> None: version = features.version(name) if not features.check(name): assert version is None @@ -46,56 +48,56 @@ def test_version(): @skip_unless_feature("webp") -def test_webp_transparency(): +def test_webp_transparency() -> None: assert features.check("transp_webp") != _webp.WebPDecoderBuggyAlpha() assert features.check("transp_webp") == _webp.HAVE_TRANSPARENCY @skip_unless_feature("webp") -def test_webp_mux(): +def test_webp_mux() -> None: assert features.check("webp_mux") == _webp.HAVE_WEBPMUX @skip_unless_feature("webp") -def test_webp_anim(): +def test_webp_anim() -> None: assert features.check("webp_anim") == _webp.HAVE_WEBPANIM @skip_unless_feature("libjpeg_turbo") -def test_libjpeg_turbo_version(): +def test_libjpeg_turbo_version() -> None: assert re.search(r"\d+\.\d+\.\d+$", features.version("libjpeg_turbo")) @skip_unless_feature("libimagequant") -def test_libimagequant_version(): +def test_libimagequant_version() -> None: assert re.search(r"\d+\.\d+\.\d+$", features.version("libimagequant")) @pytest.mark.parametrize("feature", features.modules) -def test_check_modules(feature): +def test_check_modules(feature: str) -> None: assert features.check_module(feature) in [True, False] @pytest.mark.parametrize("feature", features.codecs) -def test_check_codecs(feature): +def test_check_codecs(feature: str) -> None: assert features.check_codec(feature) in [True, False] -def test_check_warns_on_nonexistent(): +def test_check_warns_on_nonexistent() -> None: with pytest.warns(UserWarning) as cm: has_feature = features.check("typo") assert has_feature is False assert str(cm[-1].message) == "Unknown feature 'typo'." -def test_supported_modules(): +def test_supported_modules() -> None: assert isinstance(features.get_supported_modules(), list) assert isinstance(features.get_supported_codecs(), list) assert isinstance(features.get_supported_features(), list) assert isinstance(features.get_supported(), list) -def test_unsupported_codec(): +def test_unsupported_codec() -> None: # Arrange codec = "unsupported_codec" # Act / Assert @@ -105,7 +107,7 @@ def test_unsupported_codec(): features.version_codec(codec) -def test_unsupported_module(): +def test_unsupported_module() -> None: # Arrange module = "unsupported_module" # Act / Assert @@ -115,7 +117,7 @@ def test_unsupported_module(): features.version_module(module) -def test_pilinfo(): +def test_pilinfo() -> None: buf = io.StringIO() features.pilinfo(buf) out = buf.getvalue() diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index 60d951636..1b393a3ff 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import Image, ImageSequence, PngImagePlugin @@ -7,7 +10,7 @@ from PIL import Image, ImageSequence, PngImagePlugin # APNG browser support tests and fixtures via: # https://philip.html5.org/tests/apng/tests.html # (referenced from https://wiki.mozilla.org/APNG_Specification) -def test_apng_basic(): +def test_apng_basic() -> None: with Image.open("Tests/images/apng/single_frame.png") as im: assert not im.is_animated assert im.n_frames == 1 @@ -44,14 +47,14 @@ def test_apng_basic(): "filename", ("Tests/images/apng/split_fdat.png", "Tests/images/apng/split_fdat_zero_chunk.png"), ) -def test_apng_fdat(filename): +def test_apng_fdat(filename: str) -> None: with Image.open(filename) as im: im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) -def test_apng_dispose(): +def test_apng_dispose() -> None: with Image.open("Tests/images/apng/dispose_op_none.png") as im: im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) @@ -83,7 +86,7 @@ def test_apng_dispose(): assert im.getpixel((64, 32)) == (0, 0, 0, 0) -def test_apng_dispose_region(): +def test_apng_dispose_region() -> None: with Image.open("Tests/images/apng/dispose_op_none_region.png") as im: im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) @@ -105,7 +108,7 @@ def test_apng_dispose_region(): assert im.getpixel((64, 32)) == (0, 255, 0, 255) -def test_apng_dispose_op_previous_frame(): +def test_apng_dispose_op_previous_frame() -> None: # Test that the dispose settings being used are from the previous frame # # Image created with: @@ -130,14 +133,14 @@ def test_apng_dispose_op_previous_frame(): assert im.getpixel((0, 0)) == (255, 0, 0, 255) -def test_apng_dispose_op_background_p_mode(): +def test_apng_dispose_op_background_p_mode() -> None: with Image.open("Tests/images/apng/dispose_op_background_p_mode.png") as im: im.seek(1) im.load() assert im.size == (128, 64) -def test_apng_blend(): +def test_apng_blend() -> None: with Image.open("Tests/images/apng/blend_op_source_solid.png") as im: im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) @@ -164,20 +167,20 @@ def test_apng_blend(): assert im.getpixel((64, 32)) == (0, 255, 0, 255) -def test_apng_blend_transparency(): +def test_apng_blend_transparency() -> None: 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(): +def test_apng_chunk_order() -> None: with Image.open("Tests/images/apng/fctl_actl.png") as im: im.seek(im.n_frames - 1) assert im.getpixel((0, 0)) == (0, 255, 0, 255) assert im.getpixel((64, 32)) == (0, 255, 0, 255) -def test_apng_delay(): +def test_apng_delay() -> None: with Image.open("Tests/images/apng/delay.png") as im: im.seek(1) assert im.info.get("duration") == 500.0 @@ -217,7 +220,7 @@ def test_apng_delay(): assert im.info.get("duration") == 1000.0 -def test_apng_num_plays(): +def test_apng_num_plays() -> None: with Image.open("Tests/images/apng/num_plays.png") as im: assert im.info.get("loop") == 0 @@ -225,7 +228,7 @@ def test_apng_num_plays(): assert im.info.get("loop") == 1 -def test_apng_mode(): +def test_apng_mode() -> None: with Image.open("Tests/images/apng/mode_16bit.png") as im: assert im.mode == "RGBA" im.seek(im.n_frames - 1) @@ -266,7 +269,7 @@ def test_apng_mode(): assert im.getpixel((64, 32)) == (0, 0, 255, 128) -def test_apng_chunk_errors(): +def test_apng_chunk_errors() -> None: with Image.open("Tests/images/apng/chunk_no_actl.png") as im: assert not im.is_animated @@ -291,7 +294,7 @@ def test_apng_chunk_errors(): im.seek(im.n_frames - 1) -def test_apng_syntax_errors(): +def test_apng_syntax_errors() -> None: with pytest.warns(UserWarning): with Image.open("Tests/images/apng/syntax_num_frames_zero.png") as im: assert not im.is_animated @@ -335,14 +338,14 @@ def test_apng_syntax_errors(): "sequence_fdat_fctl.png", ), ) -def test_apng_sequence_errors(test_file): +def test_apng_sequence_errors(test_file: str) -> None: with pytest.raises(SyntaxError): with Image.open(f"Tests/images/apng/{test_file}") as im: im.seek(im.n_frames - 1) im.load() -def test_apng_save(tmp_path): +def test_apng_save(tmp_path: Path) -> None: with Image.open("Tests/images/apng/single_frame.png") as im: test_file = str(tmp_path / "temp.png") im.save(test_file, save_all=True) @@ -373,7 +376,7 @@ def test_apng_save(tmp_path): assert im.getpixel((64, 32)) == (0, 255, 0, 255) -def test_apng_save_alpha(tmp_path): +def test_apng_save_alpha(tmp_path: Path) -> None: test_file = str(tmp_path / "temp.png") im = Image.new("RGBA", (1, 1), (255, 0, 0, 255)) @@ -387,7 +390,7 @@ def test_apng_save_alpha(tmp_path): assert reloaded.getpixel((0, 0)) == (255, 0, 0, 127) -def test_apng_save_split_fdat(tmp_path): +def test_apng_save_split_fdat(tmp_path: Path) -> None: # test to make sure we do not generate sequence errors when writing # frames with image data spanning multiple fdAT chunks (in this case # both the default image and first animation frame will span multiple @@ -411,7 +414,7 @@ def test_apng_save_split_fdat(tmp_path): assert exception is None -def test_apng_save_duration_loop(tmp_path): +def test_apng_save_duration_loop(tmp_path: Path) -> None: test_file = str(tmp_path / "temp.png") with Image.open("Tests/images/apng/delay.png") as im: frames = [] @@ -474,7 +477,7 @@ def test_apng_save_duration_loop(tmp_path): assert im.info["duration"] == 600 -def test_apng_save_disposal(tmp_path): +def test_apng_save_disposal(tmp_path: Path) -> None: test_file = str(tmp_path / "temp.png") size = (128, 64) red = Image.new("RGBA", size, (255, 0, 0, 255)) @@ -575,7 +578,7 @@ def test_apng_save_disposal(tmp_path): assert im.getpixel((64, 32)) == (0, 0, 0, 0) -def test_apng_save_disposal_previous(tmp_path): +def test_apng_save_disposal_previous(tmp_path: Path) -> None: test_file = str(tmp_path / "temp.png") size = (128, 64) blue = Image.new("RGBA", size, (0, 0, 255, 255)) @@ -597,7 +600,7 @@ def test_apng_save_disposal_previous(tmp_path): assert im.getpixel((64, 32)) == (0, 255, 0, 255) -def test_apng_save_blend(tmp_path): +def test_apng_save_blend(tmp_path: Path) -> None: test_file = str(tmp_path / "temp.png") size = (128, 64) red = Image.new("RGBA", size, (255, 0, 0, 255)) @@ -665,7 +668,17 @@ def test_apng_save_blend(tmp_path): assert im.getpixel((0, 0)) == (0, 255, 0, 255) -def test_seek_after_close(): +def test_apng_save_size(tmp_path: Path) -> None: + test_file = str(tmp_path / "temp.png") + + im = Image.new("L", (100, 100)) + im.save(test_file, save_all=True, append_images=[Image.new("L", (200, 200))]) + + with Image.open(test_file) as reloaded: + assert reloaded.size == (200, 200) + + +def test_seek_after_close() -> None: im = Image.open("Tests/images/apng/delay.png") im.seek(1) im.close() @@ -677,7 +690,9 @@ def test_seek_after_close(): @pytest.mark.parametrize("mode", ("RGBA", "RGB", "P")) @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): +def test_different_modes_in_later_frames( + mode: str, default_image: bool, duplicate: bool, tmp_path: Path +) -> None: test_file = str(tmp_path / "temp.png") im = Image.new("L", (1, 1)) @@ -689,3 +704,12 @@ def test_different_modes_in_later_frames(mode, default_image, duplicate, tmp_pat ) with Image.open(test_file) as reloaded: assert reloaded.mode == mode + + +def test_apng_repeated_seeks_give_correct_info() -> None: + with Image.open("Tests/images/apng/different_durations.png") as im: + for i in range(3): + im.seek(0) + assert im.info["duration"] == 4000 + im.seek(1) + assert im.info["duration"] == 1000 diff --git a/Tests/test_file_blp.py b/Tests/test_file_blp.py index 4c1e38d1d..1e2f20c40 100644 --- a/Tests/test_file_blp.py +++ b/Tests/test_file_blp.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import Image @@ -11,7 +14,7 @@ from .helper import ( ) -def test_load_blp1(): +def test_load_blp1() -> None: with Image.open("Tests/images/blp/blp1_jpeg.blp") as im: assert_image_equal_tofile(im, "Tests/images/blp/blp1_jpeg.png") @@ -19,22 +22,22 @@ def test_load_blp1(): im.load() -def test_load_blp2_raw(): +def test_load_blp2_raw() -> None: with Image.open("Tests/images/blp/blp2_raw.blp") as im: assert_image_equal_tofile(im, "Tests/images/blp/blp2_raw.png") -def test_load_blp2_dxt1(): +def test_load_blp2_dxt1() -> None: with Image.open("Tests/images/blp/blp2_dxt1.blp") as im: assert_image_equal_tofile(im, "Tests/images/blp/blp2_dxt1.png") -def test_load_blp2_dxt1a(): +def test_load_blp2_dxt1a() -> None: with Image.open("Tests/images/blp/blp2_dxt1a.blp") as im: assert_image_equal_tofile(im, "Tests/images/blp/blp2_dxt1a.png") -def test_save(tmp_path): +def test_save(tmp_path: Path) -> None: f = str(tmp_path / "temp.blp") for version in ("BLP1", "BLP2"): @@ -68,7 +71,7 @@ def test_save(tmp_path): "Tests/images/timeout-ef9112a065e7183fa7faa2e18929b03e44ee16bf.blp", ], ) -def test_crashes(test_file): +def test_crashes(test_file: str) -> None: with open(test_file, "rb") as f: with Image.open(f) as im: with pytest.raises(OSError): diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index 4cc92c5f6..1eaff0c7d 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -1,5 +1,7 @@ from __future__ import annotations + import io +from pathlib import Path import pytest @@ -13,8 +15,8 @@ from .helper import ( ) -def test_sanity(tmp_path): - def roundtrip(im): +def test_sanity(tmp_path: Path) -> None: + def roundtrip(im: Image.Image) -> None: outfile = str(tmp_path / "temp.bmp") im.save(outfile, "BMP") @@ -34,20 +36,20 @@ def test_sanity(tmp_path): roundtrip(hopper("RGB")) -def test_invalid_file(): +def test_invalid_file() -> None: with open("Tests/images/flower.jpg", "rb") as fp: with pytest.raises(SyntaxError): BmpImagePlugin.BmpImageFile(fp) -def test_fallback_if_mmap_errors(): +def test_fallback_if_mmap_errors() -> None: # This image has been truncated, # so that the buffer is not large enough when using mmap with Image.open("Tests/images/mmap_error.bmp") as im: assert_image_equal_tofile(im, "Tests/images/pal8_offset.bmp") -def test_save_to_bytes(): +def test_save_to_bytes() -> None: output = io.BytesIO() im = hopper() im.save(output, "BMP") @@ -59,7 +61,7 @@ def test_save_to_bytes(): assert reloaded.format == "BMP" -def test_small_palette(tmp_path): +def test_small_palette(tmp_path: Path) -> None: im = Image.new("P", (1, 1)) colors = [0, 0, 0, 125, 125, 125, 255, 255, 255] im.putpalette(colors) @@ -71,7 +73,7 @@ def test_small_palette(tmp_path): assert reloaded.getpalette() == colors -def test_save_too_large(tmp_path): +def test_save_too_large(tmp_path: Path) -> None: outfile = str(tmp_path / "temp.bmp") with Image.new("RGB", (1, 1)) as im: im._size = (37838, 37838) @@ -79,7 +81,7 @@ def test_save_too_large(tmp_path): im.save(outfile) -def test_dpi(): +def test_dpi() -> None: dpi = (72, 72) output = io.BytesIO() @@ -91,7 +93,7 @@ def test_dpi(): assert reloaded.info["dpi"] == (72.008961115161, 72.008961115161) -def test_save_bmp_with_dpi(tmp_path): +def test_save_bmp_with_dpi(tmp_path: Path) -> None: # Test for #1301 # Arrange outfile = str(tmp_path / "temp.jpg") @@ -109,7 +111,7 @@ def test_save_bmp_with_dpi(tmp_path): assert reloaded.format == "JPEG" -def test_save_float_dpi(tmp_path): +def test_save_float_dpi(tmp_path: Path) -> None: outfile = str(tmp_path / "temp.bmp") with Image.open("Tests/images/hopper.bmp") as im: im.save(outfile, dpi=(72.21216100543306, 72.21216100543306)) @@ -117,7 +119,7 @@ def test_save_float_dpi(tmp_path): assert reloaded.info["dpi"] == (72.21216100543306, 72.21216100543306) -def test_load_dib(): +def test_load_dib() -> None: # test for #1293, Imagegrab returning Unsupported Bitfields Format with Image.open("Tests/images/clipboard.dib") as im: assert im.format == "DIB" @@ -126,7 +128,7 @@ def test_load_dib(): assert_image_equal_tofile(im, "Tests/images/clipboard_target.png") -def test_save_dib(tmp_path): +def test_save_dib(tmp_path: Path) -> None: outfile = str(tmp_path / "temp.dib") with Image.open("Tests/images/clipboard.dib") as im: @@ -138,7 +140,7 @@ def test_save_dib(tmp_path): assert_image_equal(im, reloaded) -def test_rgba_bitfields(): +def test_rgba_bitfields() -> None: # 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: @@ -156,7 +158,7 @@ def test_rgba_bitfields(): ) -def test_rle8(): +def test_rle8() -> None: with Image.open("Tests/images/hopper_rle8.bmp") as im: assert_image_similar_tofile(im.convert("RGB"), "Tests/images/hopper.bmp", 12) @@ -176,7 +178,7 @@ def test_rle8(): im.load() -def test_rle4(): +def test_rle4() -> None: with Image.open("Tests/images/bmp/g/pal4rle.bmp") as im: assert_image_similar_tofile(im, "Tests/images/bmp/g/pal4.bmp", 12) @@ -192,7 +194,7 @@ def test_rle4(): ("Tests/images/bmp/g/pal8rle.bmp", 1064), ), ) -def test_rle8_eof(file_name, length): +def test_rle8_eof(file_name: str, length: int) -> None: with open(file_name, "rb") as fp: data = fp.read(length) with Image.open(io.BytesIO(data)) as im: @@ -200,7 +202,7 @@ def test_rle8_eof(file_name, length): im.load() -def test_offset(): +def test_offset() -> None: # This image has been hexedited # to exclude the palette size from the pixel data offset with Image.open("Tests/images/pal8_offset.bmp") as im: diff --git a/Tests/test_file_bufrstub.py b/Tests/test_file_bufrstub.py index 5780232a2..3dd24533a 100644 --- a/Tests/test_file_bufrstub.py +++ b/Tests/test_file_bufrstub.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import BufrStubImagePlugin, Image @@ -8,7 +11,7 @@ from .helper import hopper TEST_FILE = "Tests/images/gfs.t06z.rassda.tm00.bufr_d" -def test_open(): +def test_open() -> None: # Act with Image.open(TEST_FILE) as im: # Assert @@ -19,7 +22,7 @@ def test_open(): assert im.size == (1, 1) -def test_invalid_file(): +def test_invalid_file() -> None: # Arrange invalid_file = "Tests/images/flower.jpg" @@ -28,7 +31,7 @@ def test_invalid_file(): BufrStubImagePlugin.BufrStubImageFile(invalid_file) -def test_load(): +def test_load() -> None: # Arrange with Image.open(TEST_FILE) as im: # Act / Assert: stub cannot load without an implemented handler @@ -36,7 +39,7 @@ def test_load(): im.load() -def test_save(tmp_path): +def test_save(tmp_path: Path) -> None: # Arrange im = hopper() tmpfile = str(tmp_path / "temp.bufr") @@ -46,13 +49,13 @@ def test_save(tmp_path): im.save(tmpfile) -def test_handler(tmp_path): +def test_handler(tmp_path: Path) -> None: class TestHandler: opened = False loaded = False saved = False - def open(self, im): + def open(self, im) -> None: self.opened = True def load(self, im): @@ -60,7 +63,7 @@ def test_handler(tmp_path): im.fp.close() return Image.new("RGB", (1, 1)) - def save(self, im, fp, filename): + def save(self, im, fp, filename) -> None: self.saved = True handler = TestHandler() diff --git a/Tests/test_file_container.py b/Tests/test_file_container.py index 0da5d3824..7f76fb47a 100644 --- a/Tests/test_file_container.py +++ b/Tests/test_file_container.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from typing import Literal + import pytest from PIL import ContainerIO, Image @@ -8,21 +11,28 @@ from .helper import hopper TEST_FILE = "Tests/images/dummy.container" -def test_sanity(): +def test_sanity() -> None: dir(Image) dir(ContainerIO) -def test_isatty(): +def test_isatty() -> None: with hopper() as im: container = ContainerIO.ContainerIO(im, 0, 0) assert container.isatty() is False -def test_seek_mode_0(): +@pytest.mark.parametrize( + "mode, expected_position", + ( + (0, 33), + (1, 66), + (2, 100), + ), +) +def test_seek_mode(mode: Literal[0, 1, 2], expected_position: int) -> None: # Arrange - mode = 0 with open(TEST_FILE, "rb") as fh: container = ContainerIO.ContainerIO(fh, 22, 100) @@ -31,39 +41,11 @@ def test_seek_mode_0(): container.seek(33, mode) # Assert - assert container.tell() == 33 - - -def test_seek_mode_1(): - # Arrange - mode = 1 - with open(TEST_FILE, "rb") as fh: - container = ContainerIO.ContainerIO(fh, 22, 100) - - # Act - container.seek(33, mode) - container.seek(33, mode) - - # Assert - assert container.tell() == 66 - - -def test_seek_mode_2(): - # Arrange - mode = 2 - with open(TEST_FILE, "rb") as fh: - container = ContainerIO.ContainerIO(fh, 22, 100) - - # Act - container.seek(33, mode) - container.seek(33, mode) - - # Assert - assert container.tell() == 100 + assert container.tell() == expected_position @pytest.mark.parametrize("bytesmode", (True, False)) -def test_read_n0(bytesmode): +def test_read_n0(bytesmode: bool) -> None: # Arrange with open(TEST_FILE, "rb" if bytesmode else "r") as fh: container = ContainerIO.ContainerIO(fh, 22, 100) @@ -79,7 +61,7 @@ def test_read_n0(bytesmode): @pytest.mark.parametrize("bytesmode", (True, False)) -def test_read_n(bytesmode): +def test_read_n(bytesmode: bool) -> None: # Arrange with open(TEST_FILE, "rb" if bytesmode else "r") as fh: container = ContainerIO.ContainerIO(fh, 22, 100) @@ -95,7 +77,7 @@ def test_read_n(bytesmode): @pytest.mark.parametrize("bytesmode", (True, False)) -def test_read_eof(bytesmode): +def test_read_eof(bytesmode: bool) -> None: # Arrange with open(TEST_FILE, "rb" if bytesmode else "r") as fh: container = ContainerIO.ContainerIO(fh, 22, 100) @@ -111,7 +93,7 @@ def test_read_eof(bytesmode): @pytest.mark.parametrize("bytesmode", (True, False)) -def test_readline(bytesmode): +def test_readline(bytesmode: bool) -> None: # Arrange with open(TEST_FILE, "rb" if bytesmode else "r") as fh: container = ContainerIO.ContainerIO(fh, 0, 120) @@ -126,7 +108,7 @@ def test_readline(bytesmode): @pytest.mark.parametrize("bytesmode", (True, False)) -def test_readlines(bytesmode): +def test_readlines(bytesmode: bool) -> None: # Arrange expected = [ "This is line 1\n", diff --git a/Tests/test_file_cur.py b/Tests/test_file_cur.py index 08c3257f9..dbf1b866d 100644 --- a/Tests/test_file_cur.py +++ b/Tests/test_file_cur.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import CurImagePlugin, Image @@ -6,7 +7,7 @@ from PIL import CurImagePlugin, Image TEST_FILE = "Tests/images/deerstalker.cur" -def test_sanity(): +def test_sanity() -> None: with Image.open(TEST_FILE) as im: assert im.size == (32, 32) assert isinstance(im, CurImagePlugin.CurImageFile) @@ -16,7 +17,7 @@ def test_sanity(): assert im.getpixel((16, 16)) == (84, 87, 86, 255) -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): diff --git a/Tests/test_file_dcx.py b/Tests/test_file_dcx.py index 25e4badbc..65337cad9 100644 --- a/Tests/test_file_dcx.py +++ b/Tests/test_file_dcx.py @@ -1,4 +1,5 @@ from __future__ import annotations + import warnings import pytest @@ -11,7 +12,7 @@ from .helper import assert_image_equal, hopper, is_pypy TEST_FILE = "Tests/images/hopper.dcx" -def test_sanity(): +def test_sanity() -> None: # Arrange # Act @@ -24,8 +25,8 @@ def test_sanity(): @pytest.mark.skipif(is_pypy(), reason="Requires CPython") -def test_unclosed_file(): - def open(): +def test_unclosed_file() -> None: + def open() -> None: im = Image.open(TEST_FILE) im.load() @@ -33,26 +34,26 @@ def test_unclosed_file(): open() -def test_closed_file(): +def test_closed_file() -> None: with warnings.catch_warnings(): im = Image.open(TEST_FILE) im.load() im.close() -def test_context_manager(): +def test_context_manager() -> None: with warnings.catch_warnings(): with Image.open(TEST_FILE) as im: im.load() -def test_invalid_file(): +def test_invalid_file() -> None: with open("Tests/images/flower.jpg", "rb") as fp: with pytest.raises(SyntaxError): DcxImagePlugin.DcxImageFile(fp) -def test_tell(): +def test_tell() -> None: # Arrange with Image.open(TEST_FILE) as im: # Act @@ -62,13 +63,13 @@ def test_tell(): assert frame == 0 -def test_n_frames(): +def test_n_frames() -> None: with Image.open(TEST_FILE) as im: assert im.n_frames == 1 assert not im.is_animated -def test_eoferror(): +def test_eoferror() -> None: with Image.open(TEST_FILE) as im: n_frames = im.n_frames @@ -81,7 +82,7 @@ def test_eoferror(): im.seek(n_frames - 1) -def test_seek_too_far(): +def test_seek_too_far() -> None: # Arrange with Image.open(TEST_FILE) as im: frame = 999 # too big on purpose diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 2d60fbb64..ebc0e89a1 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -1,6 +1,9 @@ """Test DdsImagePlugin""" + from __future__ import annotations + from io import BytesIO +from pathlib import Path import pytest @@ -32,6 +35,7 @@ TEST_FILE_DX10_R8G8B8A8_UNORM_SRGB = "Tests/images/DXGI_FORMAT_R8G8B8A8_UNORM_SR 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_BGR15 = "Tests/images/bgr15.dds" TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA = "Tests/images/uncompressed_rgb.dds" @@ -44,7 +48,7 @@ TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA = "Tests/images/uncompressed_rgb.dds" TEST_FILE_DX10_BC1_TYPELESS, ), ) -def test_sanity_dxt1_bc1(image_path): +def test_sanity_dxt1_bc1(image_path: str) -> None: """Check DXT1 and BC1 images can be opened""" with Image.open(TEST_FILE_DXT1.replace(".dds", ".png")) as target: target = target.convert("RGBA") @@ -58,7 +62,7 @@ def test_sanity_dxt1_bc1(image_path): assert_image_equal(im, target) -def test_sanity_dxt3(): +def test_sanity_dxt3() -> None: """Check DXT3 images can be opened""" with Image.open(TEST_FILE_DXT3) as im: @@ -71,7 +75,7 @@ def test_sanity_dxt3(): assert_image_equal_tofile(im, TEST_FILE_DXT3.replace(".dds", ".png")) -def test_sanity_dxt5(): +def test_sanity_dxt5() -> None: """Check DXT5 images can be opened""" with Image.open(TEST_FILE_DXT5) as im: @@ -92,7 +96,7 @@ def test_sanity_dxt5(): TEST_FILE_BC4U, ), ) -def test_sanity_ati1_bc4u(image_path): +def test_sanity_ati1_bc4u(image_path: str) -> None: """Check ATI1 and BC4U images can be opened""" with Image.open(image_path) as im: @@ -113,7 +117,7 @@ def test_sanity_ati1_bc4u(image_path): TEST_FILE_DX10_BC4_TYPELESS, ), ) -def test_dx10_bc4(image_path): +def test_dx10_bc4(image_path: str) -> None: """Check DX10 BC4 images can be opened""" with Image.open(image_path) as im: @@ -134,7 +138,7 @@ def test_dx10_bc4(image_path): TEST_FILE_BC5U, ), ) -def test_sanity_ati2_bc5u(image_path): +def test_sanity_ati2_bc5u(image_path: str) -> None: """Check ATI2 and BC5U images can be opened""" with Image.open(image_path) as im: @@ -158,7 +162,7 @@ def test_sanity_ati2_bc5u(image_path): (TEST_FILE_BC5S, TEST_FILE_BC5S), ), ) -def test_dx10_bc5(image_path, expected_path): +def test_dx10_bc5(image_path: str, expected_path: str) -> None: """Check DX10 BC5 images can be opened""" with Image.open(image_path) as im: @@ -172,7 +176,7 @@ def test_dx10_bc5(image_path, expected_path): @pytest.mark.parametrize("image_path", (TEST_FILE_BC6H, TEST_FILE_BC6HS)) -def test_dx10_bc6h(image_path): +def test_dx10_bc6h(image_path: str) -> None: """Check DX10 BC6H/BC6HS images can be opened""" with Image.open(image_path) as im: @@ -185,7 +189,7 @@ def test_dx10_bc6h(image_path): assert_image_equal_tofile(im, image_path.replace(".dds", ".png")) -def test_dx10_bc7(): +def test_dx10_bc7() -> None: """Check DX10 images can be opened""" with Image.open(TEST_FILE_DX10_BC7) as im: @@ -198,7 +202,7 @@ def test_dx10_bc7(): assert_image_equal_tofile(im, TEST_FILE_DX10_BC7.replace(".dds", ".png")) -def test_dx10_bc7_unorm_srgb(): +def test_dx10_bc7_unorm_srgb() -> None: """Check DX10 unsigned normalized integer images can be opened""" with Image.open(TEST_FILE_DX10_BC7_UNORM_SRGB) as im: @@ -214,7 +218,7 @@ def test_dx10_bc7_unorm_srgb(): ) -def test_dx10_r8g8b8a8(): +def test_dx10_r8g8b8a8() -> None: """Check DX10 images can be opened""" with Image.open(TEST_FILE_DX10_R8G8B8A8) as im: @@ -227,7 +231,7 @@ def test_dx10_r8g8b8a8(): assert_image_equal_tofile(im, TEST_FILE_DX10_R8G8B8A8.replace(".dds", ".png")) -def test_dx10_r8g8b8a8_unorm_srgb(): +def test_dx10_r8g8b8a8_unorm_srgb() -> None: """Check DX10 unsigned normalized integer images can be opened""" with Image.open(TEST_FILE_DX10_R8G8B8A8_UNORM_SRGB) as im: @@ -249,10 +253,11 @@ def test_dx10_r8g8b8a8_unorm_srgb(): ("L", (128, 128), TEST_FILE_UNCOMPRESSED_L), ("LA", (128, 128), TEST_FILE_UNCOMPRESSED_L_WITH_ALPHA), ("RGB", (128, 128), TEST_FILE_UNCOMPRESSED_RGB), + ("RGB", (128, 128), TEST_FILE_UNCOMPRESSED_BGR15), ("RGBA", (800, 600), TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA), ], ) -def test_uncompressed(mode, size, test_file): +def test_uncompressed(mode: str, size: tuple[int, int], test_file: str) -> None: """Check uncompressed images can be opened""" with Image.open(test_file) as im: @@ -263,7 +268,7 @@ def test_uncompressed(mode, size, test_file): assert_image_equal_tofile(im, test_file.replace(".dds", ".png")) -def test__accept_true(): +def test__accept_true() -> None: """Check valid prefix""" # Arrange prefix = b"DDS etc" @@ -275,7 +280,7 @@ def test__accept_true(): assert output -def test__accept_false(): +def test__accept_false() -> None: """Check invalid prefix""" # Arrange prefix = b"something invalid" @@ -287,19 +292,19 @@ def test__accept_false(): assert not output -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): DdsImagePlugin.DdsImageFile(invalid_file) -def test_short_header(): +def test_short_header() -> None: """Check a short header""" with open(TEST_FILE_DXT5, "rb") as f: img_file = f.read() - def short_header(): + def short_header() -> None: with Image.open(BytesIO(img_file[:119])): pass # pragma: no cover @@ -307,13 +312,13 @@ def test_short_header(): short_header() -def test_short_file(): +def test_short_file() -> None: """Check that the appropriate error is thrown for a short file""" with open(TEST_FILE_DXT5, "rb") as f: img_file = f.read() - def short_file(): + def short_file() -> None: with Image.open(BytesIO(img_file[:-100])) as im: im.load() @@ -321,7 +326,7 @@ def test_short_file(): short_file() -def test_dxt5_colorblock_alpha_issue_4142(): +def test_dxt5_colorblock_alpha_issue_4142() -> None: """Check that colorblocks are decoded correctly in DXT5""" with Image.open("Tests/images/dxt5-colorblock-alpha-issue-4142.dds") as im: @@ -336,21 +341,14 @@ def test_dxt5_colorblock_alpha_issue_4142(): assert px[2] != 0 -def test_palette(): +def test_palette() -> None: 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): +def test_unsupported_bitcount() -> None: with pytest.raises(OSError): - with Image.open(test_file): + with Image.open("Tests/images/unsupported_bitcount.dds"): pass @@ -361,13 +359,13 @@ def test_unsupported_bitcount(test_file): "Tests/images/unimplemented_pfflags.dds", ), ) -def test_not_implemented(test_file): +def test_not_implemented(test_file: str) -> None: with pytest.raises(NotImplementedError): with Image.open(test_file): pass -def test_save_unsupported_mode(tmp_path): +def test_save_unsupported_mode(tmp_path: Path) -> None: out = str(tmp_path / "temp.dds") im = hopper("HSV") with pytest.raises(OSError): @@ -383,7 +381,7 @@ def test_save_unsupported_mode(tmp_path): ("RGBA", "Tests/images/pil123rgba.png"), ], ) -def test_save(mode, test_file, tmp_path): +def test_save(mode: str, test_file: str, tmp_path: Path) -> None: out = str(tmp_path / "temp.dds") with Image.open(test_file) as im: assert im.mode == mode diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index 360ae11b5..d01884f96 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -1,5 +1,7 @@ from __future__ import annotations + import io +from pathlib import Path import pytest @@ -82,7 +84,7 @@ simple_eps_file_with_long_binary_data = ( ("filename", "size"), ((FILE1, (460, 352)), (FILE2, (360, 252))) ) @pytest.mark.parametrize("scale", (1, 2)) -def test_sanity(filename, size, scale): +def test_sanity(filename: str, size: tuple[int, int], scale: int) -> None: expected_size = tuple(s * scale for s in size) with Image.open(filename) as image: image.load(scale=scale) @@ -92,7 +94,7 @@ def test_sanity(filename, size, scale): @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") -def test_load(): +def test_load() -> None: with Image.open(FILE1) as im: assert im.load()[0, 0] == (255, 255, 255) @@ -100,7 +102,7 @@ def test_load(): assert im.load()[0, 0] == (255, 255, 255) -def test_binary(): +def test_binary() -> None: if HAS_GHOSTSCRIPT: assert EpsImagePlugin.gs_binary is not None else: @@ -114,41 +116,41 @@ def test_binary(): assert EpsImagePlugin.gs_windows_binary is not None -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): EpsImagePlugin.EpsImageFile(invalid_file) -def test_binary_header_only(): +def test_binary_header_only() -> None: 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): +def test_missing_version_comment(prefix: bytes) -> None: 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): +def test_missing_boundingbox_comment(prefix: bytes) -> None: 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): +def test_invalid_boundingbox_comment(prefix: bytes) -> None: 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): +def test_invalid_boundingbox_comment_valid_imagedata_comment(prefix: bytes) -> None: data = io.BytesIO( prefix + b"\n".join(simple_eps_file_with_invalid_boundingbox_valid_imagedata) ) @@ -159,21 +161,21 @@ def test_invalid_boundingbox_comment_valid_imagedata_comment(prefix): @pytest.mark.parametrize("prefix", (b"", simple_binary_header)) -def test_ascii_comment_too_long(prefix): +def test_ascii_comment_too_long(prefix: bytes) -> None: 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): +def test_long_binary_data(prefix: bytes) -> None: 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): +def test_load_long_binary_data(prefix: bytes) -> None: data = io.BytesIO(prefix + b"\n".join(simple_eps_file_with_long_binary_data)) with Image.open(data) as img: img.load() @@ -186,7 +188,7 @@ def test_load_long_binary_data(prefix): 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(): +def test_cmyk() -> None: with Image.open("Tests/images/pil_sample_cmyk.eps") as cmyk_image: assert cmyk_image.mode == "CMYK" assert cmyk_image.size == (100, 100) @@ -202,7 +204,7 @@ def test_cmyk(): @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") -def test_showpage(): +def test_showpage() -> None: # See https://github.com/python-pillow/Pillow/issues/2615 with Image.open("Tests/images/reqd_showpage.eps") as plot_image: with Image.open("Tests/images/reqd_showpage.png") as target: @@ -213,7 +215,7 @@ def test_showpage(): @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") -def test_transparency(): +def test_transparency() -> None: with Image.open("Tests/images/reqd_showpage.eps") as plot_image: plot_image.load(transparency=True) assert plot_image.mode == "RGBA" @@ -224,7 +226,7 @@ def test_transparency(): @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") -def test_file_object(tmp_path): +def test_file_object(tmp_path: Path) -> None: # issue 479 with Image.open(FILE1) as image1: with open(str(tmp_path / "temp.eps"), "wb") as fh: @@ -232,7 +234,7 @@ def test_file_object(tmp_path): @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") -def test_bytesio_object(): +def test_bytesio_object() -> None: with open(FILE1, "rb") as f: img_bytes = io.BytesIO(f.read()) @@ -245,12 +247,12 @@ def test_bytesio_object(): assert_image_similar(img, image1_scale1_compare, 5) -def test_1_mode(): +def test_1_mode() -> None: with Image.open("Tests/images/1.eps") as im: assert im.mode == "1" -def test_image_mode_not_supported(tmp_path): +def test_image_mode_not_supported(tmp_path: Path) -> None: im = hopper("RGBA") tmpfile = str(tmp_path / "temp.eps") with pytest.raises(ValueError): @@ -259,7 +261,7 @@ def test_image_mode_not_supported(tmp_path): @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @skip_unless_feature("zlib") -def test_render_scale1(): +def test_render_scale1() -> None: # We need png support for these render test # Zero bounding box @@ -270,7 +272,7 @@ def test_render_scale1(): image1_scale1_compare.load() assert_image_similar(image1_scale1, image1_scale1_compare, 5) - # Non-Zero bounding box + # Non-zero bounding box with Image.open(FILE2) as image2_scale1: image2_scale1.load() with Image.open(FILE2_COMPARE) as image2_scale1_compare: @@ -281,7 +283,7 @@ def test_render_scale1(): @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @skip_unless_feature("zlib") -def test_render_scale2(): +def test_render_scale2() -> None: # We need png support for these render test # Zero bounding box @@ -292,7 +294,7 @@ def test_render_scale2(): image1_scale2_compare.load() assert_image_similar(image1_scale2, image1_scale2_compare, 5) - # Non-Zero bounding box + # Non-zero bounding box with Image.open(FILE2) as image2_scale2: image2_scale2.load(scale=2) with Image.open(FILE2_COMPARE_SCALE2) as image2_scale2_compare: @@ -303,7 +305,7 @@ def test_render_scale2(): @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @pytest.mark.parametrize("filename", (FILE1, FILE2, "Tests/images/illu10_preview.eps")) -def test_resize(filename): +def test_resize(filename: str) -> None: with Image.open(filename) as im: new_size = (100, 100) im = im.resize(new_size) @@ -312,7 +314,7 @@ def test_resize(filename): @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @pytest.mark.parametrize("filename", (FILE1, FILE2)) -def test_thumbnail(filename): +def test_thumbnail(filename: str) -> None: # Issue #619 with Image.open(filename) as im: new_size = (100, 100) @@ -320,20 +322,20 @@ def test_thumbnail(filename): assert max(im.size) == max(new_size) -def test_read_binary_preview(): +def test_read_binary_preview() -> None: # Issue 302 # open image with binary preview with Image.open(FILE3): pass -def test_readline_psfile(tmp_path): +def test_readline_psfile(tmp_path: Path) -> None: # 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"] strings = ["something", "else", "baz", "bif"] - def _test_readline(t, ending): + def _test_readline(t: EpsImagePlugin.PSFile, ending: str) -> None: ending = "Failure with line ending: %s" % ( "".join("%s" % ord(s) for s in ending) ) @@ -342,13 +344,13 @@ def test_readline_psfile(tmp_path): assert t.readline().strip("\r\n") == "baz", ending assert t.readline().strip("\r\n") == "bif", ending - def _test_readline_io_psfile(test_string, ending): + def _test_readline_io_psfile(test_string: str, ending: str) -> None: f = io.BytesIO(test_string.encode("latin-1")) with pytest.warns(DeprecationWarning): t = EpsImagePlugin.PSFile(f) _test_readline(t, ending) - def _test_readline_file_psfile(test_string, ending): + def _test_readline_file_psfile(test_string: str, ending: str) -> None: f = str(tmp_path / "temp.txt") with open(f, "wb") as w: w.write(test_string.encode("latin-1")) @@ -364,7 +366,7 @@ def test_readline_psfile(tmp_path): _test_readline_file_psfile(s, ending) -def test_psfile_deprecation(): +def test_psfile_deprecation() -> None: with pytest.warns(DeprecationWarning): EpsImagePlugin.PSFile(None) @@ -374,7 +376,7 @@ def test_psfile_deprecation(): "line_ending", (b"\r\n", b"\n", b"\n\r", b"\r"), ) -def test_readline(prefix, line_ending): +def test_readline(prefix: bytes, line_ending: bytes) -> None: simple_file = prefix + line_ending.join(simple_eps_file_with_comments) data = io.BytesIO(simple_file) test_file = EpsImagePlugin.EpsImageFile(data) @@ -392,14 +394,14 @@ def test_readline(prefix, line_ending): "Tests/images/illuCS6_preview.eps", ), ) -def test_open_eps(filename): +def test_open_eps(filename: str) -> None: # https://github.com/python-pillow/Pillow/issues/1104 with Image.open(filename) as img: assert img.mode == "RGB" @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") -def test_emptyline(): +def test_emptyline() -> None: # Test file includes an empty line in the header data emptyline_file = "Tests/images/zero_bb_emptyline.eps" @@ -415,14 +417,14 @@ def test_emptyline(): "test_file", ["Tests/images/timeout-d675703545fee17acab56e5fec644c19979175de.eps"], ) -def test_timeout(test_file): +def test_timeout(test_file: str) -> None: with open(test_file, "rb") as f: with pytest.raises(UnidentifiedImageError): with Image.open(f): pass -def test_bounding_box_in_trailer(): +def test_bounding_box_in_trailer() -> None: # 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( @@ -431,7 +433,15 @@ def test_bounding_box_in_trailer(): assert trailer_image.size == header_image.size -def test_eof_before_bounding_box(): +def test_eof_before_bounding_box() -> None: with pytest.raises(OSError): with Image.open("Tests/images/zero_bb_eof_before_boundingbox.eps"): pass + + +def test_invalid_data_after_eof() -> None: + with open("Tests/images/illuCS6_preview.eps", "rb") as f: + img_bytes = io.BytesIO(f.read() + b"\r\n%" + (b" " * 255)) + + with Image.open(img_bytes) as img: + assert img.mode == "RGB" diff --git a/Tests/test_file_fits.py b/Tests/test_file_fits.py index 1383f9c5c..cce0b05cd 100644 --- a/Tests/test_file_fits.py +++ b/Tests/test_file_fits.py @@ -1,4 +1,5 @@ from __future__ import annotations + from io import BytesIO import pytest @@ -10,7 +11,7 @@ from .helper import assert_image_equal, hopper TEST_FILE = "Tests/images/hopper.fits" -def test_open(): +def test_open() -> None: # Act with Image.open(TEST_FILE) as im: # Assert @@ -21,7 +22,7 @@ def test_open(): assert_image_equal(im, hopper("L")) -def test_invalid_file(): +def test_invalid_file() -> None: # Arrange invalid_file = "Tests/images/flower.jpg" @@ -30,14 +31,14 @@ def test_invalid_file(): FitsImagePlugin.FitsImageFile(invalid_file) -def test_truncated_fits(): +def test_truncated_fits() -> None: # No END to headers image_data = b"SIMPLE = T" + b" " * 50 + b"TRUNCATE" with pytest.raises(OSError): FitsImagePlugin.FitsImageFile(BytesIO(image_data)) -def test_naxis_zero(): +def test_naxis_zero() -> None: # This test image has been manually hexedited # to set the number of data axes to zero with pytest.raises(ValueError): @@ -45,7 +46,7 @@ def test_naxis_zero(): pass -def test_comment(): +def test_comment() -> None: 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 10bf36cc2..f86fb8d09 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -1,9 +1,10 @@ from __future__ import annotations + import warnings import pytest -from PIL import FliImagePlugin, Image +from PIL import FliImagePlugin, Image, ImageFile from .helper import assert_image_equal, assert_image_equal_tofile, is_pypy @@ -11,11 +12,14 @@ from .helper import assert_image_equal, assert_image_equal_tofile, is_pypy # save as...-> hopper.fli, default options. static_test_file = "Tests/images/hopper.fli" -# From https://samples.libav.org/fli-flc/ +# From https://samples.ffmpeg.org/fli-flc/ animated_test_file = "Tests/images/a.fli" +# From https://samples.ffmpeg.org/fli-flc/ +animated_test_file_with_prefix_chunk = "Tests/images/2422.flc" -def test_sanity(): + +def test_sanity() -> None: with Image.open(static_test_file) as im: im.load() assert im.mode == "P" @@ -31,9 +35,27 @@ def test_sanity(): assert im.is_animated +def test_prefix_chunk() -> None: + ImageFile.LOAD_TRUNCATED_IMAGES = True + try: + with Image.open(animated_test_file_with_prefix_chunk) as im: + assert im.mode == "P" + assert im.size == (320, 200) + assert im.format == "FLI" + assert im.info["duration"] == 171 + assert im.is_animated + + palette = im.getpalette() + assert palette[3:6] == [255, 255, 255] + assert palette[381:384] == [204, 204, 12] + assert palette[765:] == [252, 0, 0] + finally: + ImageFile.LOAD_TRUNCATED_IMAGES = False + + @pytest.mark.skipif(is_pypy(), reason="Requires CPython") -def test_unclosed_file(): - def open(): +def test_unclosed_file() -> None: + def open() -> None: im = Image.open(static_test_file) im.load() @@ -41,14 +63,14 @@ def test_unclosed_file(): open() -def test_closed_file(): +def test_closed_file() -> None: with warnings.catch_warnings(): im = Image.open(static_test_file) im.load() im.close() -def test_seek_after_close(): +def test_seek_after_close() -> None: im = Image.open(animated_test_file) im.seek(1) im.close() @@ -57,13 +79,13 @@ def test_seek_after_close(): im.seek(0) -def test_context_manager(): +def test_context_manager() -> None: with warnings.catch_warnings(): with Image.open(static_test_file) as im: im.load() -def test_tell(): +def test_tell() -> None: # Arrange with Image.open(static_test_file) as im: # Act @@ -73,20 +95,20 @@ def test_tell(): assert frame == 0 -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): FliImagePlugin.FliImageFile(invalid_file) -def test_palette_chunk_second(): +def test_palette_chunk_second() -> None: with Image.open("Tests/images/hopper_palette_chunk_second.fli") as im: with Image.open(static_test_file) as expected: assert_image_equal(im.convert("RGB"), expected.convert("RGB")) -def test_n_frames(): +def test_n_frames() -> None: with Image.open(static_test_file) as im: assert im.n_frames == 1 assert not im.is_animated @@ -96,7 +118,7 @@ def test_n_frames(): assert im.is_animated -def test_eoferror(): +def test_eoferror() -> None: with Image.open(animated_test_file) as im: n_frames = im.n_frames @@ -109,7 +131,7 @@ def test_eoferror(): im.seek(n_frames - 1) -def test_seek_tell(): +def test_seek_tell() -> None: with Image.open(animated_test_file) as im: layer_number = im.tell() assert layer_number == 0 @@ -131,7 +153,7 @@ def test_seek_tell(): assert layer_number == 1 -def test_seek(): +def test_seek() -> None: with Image.open(animated_test_file) as im: im.seek(50) @@ -146,7 +168,7 @@ def test_seek(): ], ) @pytest.mark.timeout(timeout=3) -def test_timeouts(test_file): +def test_timeouts(test_file: str) -> None: with open(test_file, "rb") as f: with Image.open(f) as im: with pytest.raises(OSError): @@ -159,7 +181,7 @@ def test_timeouts(test_file): "Tests/images/crash-5762152299364352.fli", ], ) -def test_crash(test_file): +def test_crash(test_file: str) -> None: with open(test_file, "rb") as f: with Image.open(f) as im: with pytest.raises(OSError): diff --git a/Tests/test_file_fpx.py b/Tests/test_file_fpx.py index af3b79815..e32f30a01 100644 --- a/Tests/test_file_fpx.py +++ b/Tests/test_file_fpx.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image @@ -10,7 +11,7 @@ FpxImagePlugin = pytest.importorskip( ) -def test_sanity(): +def test_sanity() -> None: with Image.open("Tests/images/input_bw_one_band.fpx") as im: assert im.mode == "L" assert im.size == (70, 46) @@ -19,7 +20,7 @@ def test_sanity(): assert_image_equal_tofile(im, "Tests/images/input_bw_one_band.png") -def test_close(): +def test_close() -> None: with Image.open("Tests/images/input_bw_one_band.fpx") as im: pass assert im.ole.fp.closed @@ -29,7 +30,7 @@ def test_close(): assert im.ole.fp.closed -def test_invalid_file(): +def test_invalid_file() -> None: # Test an invalid OLE file invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): @@ -41,7 +42,7 @@ def test_invalid_file(): FpxImagePlugin.FpxImageFile(ole_file) -def test_fpx_invalid_number_of_bands(): +def test_fpx_invalid_number_of_bands() -> None: with pytest.raises(OSError, match="Invalid number of bands"): with Image.open("Tests/images/input_bw_five_bands.fpx"): pass diff --git a/Tests/test_file_ftex.py b/Tests/test_file_ftex.py index a494c8029..0c544245a 100644 --- a/Tests/test_file_ftex.py +++ b/Tests/test_file_ftex.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import FtexImagePlugin, Image @@ -6,18 +7,18 @@ from PIL import FtexImagePlugin, Image from .helper import assert_image_equal_tofile, assert_image_similar -def test_load_raw(): +def test_load_raw() -> None: with Image.open("Tests/images/ftex_uncompressed.ftu") as im: assert_image_equal_tofile(im, "Tests/images/ftex_uncompressed.png") -def test_load_dxt1(): +def test_load_dxt1() -> None: with Image.open("Tests/images/ftex_dxt1.ftc") as im: with Image.open("Tests/images/ftex_dxt1.png") as target: assert_image_similar(im, target.convert("RGBA"), 15) -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): diff --git a/Tests/test_file_gbr.py b/Tests/test_file_gbr.py index 7dfe05396..be98b08f2 100644 --- a/Tests/test_file_gbr.py +++ b/Tests/test_file_gbr.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import GbrImagePlugin, Image @@ -6,12 +7,12 @@ from PIL import GbrImagePlugin, Image from .helper import assert_image_equal_tofile -def test_gbr_file(): +def test_gbr_file() -> None: with Image.open("Tests/images/gbr.gbr") as im: assert_image_equal_tofile(im, "Tests/images/gbr.png") -def test_load(): +def test_load() -> None: with Image.open("Tests/images/gbr.gbr") as im: assert im.load()[0, 0] == (0, 0, 0, 0) @@ -19,14 +20,14 @@ def test_load(): assert im.load()[0, 0] == (0, 0, 0, 0) -def test_multiple_load_operations(): +def test_multiple_load_operations() -> None: with Image.open("Tests/images/gbr.gbr") as im: im.load() im.load() assert_image_equal_tofile(im, "Tests/images/gbr.png") -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): diff --git a/Tests/test_file_gd.py b/Tests/test_file_gd.py index ec80c54a1..d512df284 100644 --- a/Tests/test_file_gd.py +++ b/Tests/test_file_gd.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import GdImageFile, UnidentifiedImageError @@ -6,18 +7,18 @@ from PIL import GdImageFile, UnidentifiedImageError TEST_GD_FILE = "Tests/images/hopper.gd" -def test_sanity(): +def test_sanity() -> None: with GdImageFile.open(TEST_GD_FILE) as im: assert im.size == (128, 128) assert im.format == "GD" -def test_bad_mode(): +def test_bad_mode() -> None: with pytest.raises(ValueError): GdImageFile.open(TEST_GD_FILE, "bad mode") -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(UnidentifiedImageError): diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 78b77e974..48c70db8a 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -1,6 +1,9 @@ from __future__ import annotations + import warnings from io import BytesIO +from pathlib import Path +from typing import Generator import pytest @@ -22,7 +25,7 @@ with open(TEST_GIF, "rb") as f: data = f.read() -def test_sanity(): +def test_sanity() -> None: with Image.open(TEST_GIF) as im: im.load() assert im.mode == "P" @@ -32,8 +35,8 @@ def test_sanity(): @pytest.mark.skipif(is_pypy(), reason="Requires CPython") -def test_unclosed_file(): - def open(): +def test_unclosed_file() -> None: + def open() -> None: im = Image.open(TEST_GIF) im.load() @@ -41,14 +44,14 @@ def test_unclosed_file(): open() -def test_closed_file(): +def test_closed_file() -> None: with warnings.catch_warnings(): im = Image.open(TEST_GIF) im.load() im.close() -def test_seek_after_close(): +def test_seek_after_close() -> None: im = Image.open("Tests/images/iss634.gif") im.load() im.close() @@ -61,20 +64,20 @@ def test_seek_after_close(): im.seek(1) -def test_context_manager(): +def test_context_manager() -> None: with warnings.catch_warnings(): with Image.open(TEST_GIF) as im: im.load() -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): GifImagePlugin.GifImageFile(invalid_file) -def test_l_mode_transparency(): +def test_l_mode_transparency() -> None: with Image.open("Tests/images/no_palette_with_transparency.gif") as im: assert im.mode == "L" assert im.load()[0, 0] == 128 @@ -85,7 +88,7 @@ def test_l_mode_transparency(): assert im.load()[0, 0] == 128 -def test_l_mode_after_rgb(): +def test_l_mode_after_rgb() -> None: with Image.open("Tests/images/no_palette_after_rgb.gif") as im: im.seek(1) assert im.mode == "RGB" @@ -94,13 +97,13 @@ def test_l_mode_after_rgb(): assert im.mode == "RGB" -def test_palette_not_needed_for_second_frame(): +def test_palette_not_needed_for_second_frame() -> None: with Image.open("Tests/images/palette_not_needed_for_second_frame.gif") as im: im.seek(1) assert_image_similar(im, hopper("L").convert("RGB"), 8) -def test_strategy(): +def test_strategy() -> None: with Image.open("Tests/images/iss634.gif") as im: expected_rgb_always = im.convert("RGB") @@ -141,14 +144,14 @@ def test_strategy(): GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST -def test_optimize(): - def test_grayscale(optimize): +def test_optimize() -> None: + def test_grayscale(optimize: int) -> int: im = Image.new("L", (1, 1), 0) filename = BytesIO() im.save(filename, "GIF", optimize=optimize) return len(filename.getvalue()) - def test_bilevel(optimize): + def test_bilevel(optimize: int) -> int: im = Image.new("1", (1, 1), 0) test_file = BytesIO() im.save(test_file, "GIF", optimize=optimize) @@ -176,7 +179,9 @@ def test_optimize(): (4, 513, 256), ), ) -def test_optimize_correctness(colors, size, expected_palette_length): +def test_optimize_correctness( + colors: int, size: int, expected_palette_length: int +) -> None: # 256 color Palette image, posterize to > 128 and < 128 levels. # Size bigger and smaller than 512x512. # Check the palette for number of colors allocated. @@ -198,14 +203,14 @@ def test_optimize_correctness(colors, size, expected_palette_length): assert_image_equal(im.convert("RGB"), reloaded.convert("RGB")) -def test_optimize_full_l(): +def test_optimize_full_l() -> None: im = Image.frombytes("L", (16, 16), bytes(range(256))) test_file = BytesIO() im.save(test_file, "GIF", optimize=True) assert im.mode == "L" -def test_optimize_if_palette_can_be_reduced_by_half(): +def test_optimize_if_palette_can_be_reduced_by_half() -> None: im = Image.new("P", (8, 1)) im.palette = ImagePalette.raw("RGB", bytes((0, 0, 0) * 150)) for i in range(8): @@ -218,7 +223,7 @@ def test_optimize_if_palette_can_be_reduced_by_half(): assert len(reloaded.palette.palette) // 3 == colors -def test_full_palette_second_frame(tmp_path): +def test_full_palette_second_frame(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") im = Image.new("P", (1, 256)) @@ -239,7 +244,7 @@ def test_full_palette_second_frame(tmp_path): reloaded.getpixel((0, i)) == i -def test_roundtrip(tmp_path): +def test_roundtrip(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") im = hopper() im.save(out) @@ -247,7 +252,7 @@ def test_roundtrip(tmp_path): assert_image_similar(reread.convert("RGB"), im, 50) -def test_roundtrip2(tmp_path): +def test_roundtrip2(tmp_path: Path) -> None: # see https://github.com/python-pillow/Pillow/issues/403 out = str(tmp_path / "temp.gif") with Image.open(TEST_GIF) as im: @@ -257,7 +262,7 @@ def test_roundtrip2(tmp_path): assert_image_similar(reread.convert("RGB"), hopper(), 50) -def test_roundtrip_save_all(tmp_path): +def test_roundtrip_save_all(tmp_path: Path) -> None: # Single frame image out = str(tmp_path / "temp.gif") im = hopper() @@ -274,7 +279,7 @@ def test_roundtrip_save_all(tmp_path): assert reread.n_frames == 5 -def test_roundtrip_save_all_1(tmp_path): +def test_roundtrip_save_all_1(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") im = Image.new("1", (1, 1)) im2 = Image.new("1", (1, 1), 1) @@ -295,7 +300,7 @@ def test_roundtrip_save_all_1(tmp_path): ("Tests/images/dispose_bgnd_rgba.gif", "RGBA"), ), ) -def test_loading_multiple_palettes(path, mode): +def test_loading_multiple_palettes(path: str, mode: str) -> None: with Image.open(path) as im: assert im.mode == "P" first_frame_colors = im.palette.colors.keys() @@ -313,7 +318,7 @@ def test_loading_multiple_palettes(path, mode): assert im.load()[24, 24] not in first_frame_colors -def test_headers_saving_for_animated_gifs(tmp_path): +def test_headers_saving_for_animated_gifs(tmp_path: Path) -> None: important_headers = ["background", "version", "duration", "loop"] # Multiframe image with Image.open("Tests/images/dispose_bgnd.gif") as im: @@ -326,7 +331,7 @@ def test_headers_saving_for_animated_gifs(tmp_path): assert info[header] == reread.info[header] -def test_palette_handling(tmp_path): +def test_palette_handling(tmp_path: Path) -> None: # see https://github.com/python-pillow/Pillow/issues/513 with Image.open(TEST_GIF) as im: @@ -342,12 +347,12 @@ def test_palette_handling(tmp_path): assert_image_similar(im, reloaded.convert("RGB"), 10) -def test_palette_434(tmp_path): +def test_palette_434(tmp_path: Path) -> None: # see https://github.com/python-pillow/Pillow/issues/434 - def roundtrip(im, *args, **kwargs): + def roundtrip(im: Image.Image, **kwargs: bool) -> Image.Image: out = str(tmp_path / "temp.gif") - im.copy().save(out, *args, **kwargs) + im.copy().save(out, **kwargs) reloaded = Image.open(out) return reloaded @@ -367,7 +372,7 @@ def test_palette_434(tmp_path): @pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") -def test_save_netpbm_bmp_mode(tmp_path): +def test_save_netpbm_bmp_mode(tmp_path: Path) -> None: with Image.open(TEST_GIF) as img: img = img.convert("RGB") @@ -378,7 +383,7 @@ def test_save_netpbm_bmp_mode(tmp_path): @pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") -def test_save_netpbm_l_mode(tmp_path): +def test_save_netpbm_l_mode(tmp_path: Path) -> None: with Image.open(TEST_GIF) as img: img = img.convert("L") @@ -388,7 +393,7 @@ def test_save_netpbm_l_mode(tmp_path): assert_image_similar(img, reloaded.convert("L"), 0) -def test_seek(): +def test_seek() -> None: with Image.open("Tests/images/dispose_none.gif") as img: frame_count = 0 try: @@ -399,7 +404,7 @@ def test_seek(): assert frame_count == 5 -def test_seek_info(): +def test_seek_info() -> None: with Image.open("Tests/images/iss634.gif") as im: info = im.info.copy() @@ -409,7 +414,7 @@ def test_seek_info(): assert im.info == info -def test_seek_rewind(): +def test_seek_rewind() -> None: with Image.open("Tests/images/iss634.gif") as im: im.seek(2) im.seek(1) @@ -427,7 +432,7 @@ def test_seek_rewind(): ("Tests/images/iss634.gif", 42), ), ) -def test_n_frames(path, n_frames): +def test_n_frames(path: str, n_frames: int) -> None: # Test is_animated before n_frames with Image.open(path) as im: assert im.is_animated == (n_frames != 1) @@ -438,7 +443,7 @@ def test_n_frames(path, n_frames): assert im.is_animated == (n_frames != 1) -def test_no_change(): +def test_no_change() -> None: # Test n_frames does not change the image with Image.open("Tests/images/dispose_bgnd.gif") as im: im.seek(1) @@ -459,7 +464,7 @@ def test_no_change(): assert_image_equal(im, expected) -def test_eoferror(): +def test_eoferror() -> None: with Image.open(TEST_GIF) as im: n_frames = im.n_frames @@ -472,13 +477,13 @@ def test_eoferror(): im.seek(n_frames - 1) -def test_first_frame_transparency(): +def test_first_frame_transparency() -> None: with Image.open("Tests/images/first_frame_transparency.gif") as im: px = im.load() assert px[0, 0] == im.info["transparency"] -def test_dispose_none(): +def test_dispose_none() -> None: with Image.open("Tests/images/dispose_none.gif") as img: try: while True: @@ -488,7 +493,7 @@ def test_dispose_none(): pass -def test_dispose_none_load_end(): +def test_dispose_none_load_end() -> None: # Test image created with: # # im = Image.open("transparent.gif") @@ -501,7 +506,7 @@ def test_dispose_none_load_end(): assert_image_equal_tofile(img, "Tests/images/dispose_none_load_end_second.png") -def test_dispose_background(): +def test_dispose_background() -> None: with Image.open("Tests/images/dispose_bgnd.gif") as img: try: while True: @@ -511,7 +516,7 @@ def test_dispose_background(): pass -def test_dispose_background_transparency(): +def test_dispose_background_transparency() -> None: with Image.open("Tests/images/dispose_bgnd_transparency.gif") as img: img.seek(2) px = img.load() @@ -539,7 +544,10 @@ def test_dispose_background_transparency(): ), ), ) -def test_transparent_dispose(loading_strategy, expected_colors): +def test_transparent_dispose( + loading_strategy: GifImagePlugin.LoadingStrategy, + expected_colors: tuple[tuple[int | tuple[int, int, int, int], ...]], +) -> None: GifImagePlugin.LOADING_STRATEGY = loading_strategy try: with Image.open("Tests/images/transparent_dispose.gif") as img: @@ -552,7 +560,7 @@ def test_transparent_dispose(loading_strategy, expected_colors): GifImagePlugin.LOADING_STRATEGY = GifImagePlugin.LoadingStrategy.RGB_AFTER_FIRST -def test_dispose_previous(): +def test_dispose_previous() -> None: with Image.open("Tests/images/dispose_prev.gif") as img: try: while True: @@ -562,7 +570,7 @@ def test_dispose_previous(): pass -def test_dispose_previous_first_frame(): +def test_dispose_previous_first_frame() -> None: with Image.open("Tests/images/dispose_prev_first_frame.gif") as im: im.seek(1) assert_image_equal_tofile( @@ -570,7 +578,7 @@ def test_dispose_previous_first_frame(): ) -def test_previous_frame_loaded(): +def test_previous_frame_loaded() -> None: with Image.open("Tests/images/dispose_none.gif") as img: img.load() img.seek(1) @@ -581,7 +589,7 @@ def test_previous_frame_loaded(): assert_image_equal(img_skipped, img) -def test_save_dispose(tmp_path): +def test_save_dispose(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") im_list = [ Image.new("L", (100, 100), "#000"), @@ -609,7 +617,7 @@ def test_save_dispose(tmp_path): assert img.disposal_method == i + 1 -def test_dispose2_palette(tmp_path): +def test_dispose2_palette(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") # Four colors: white, gray, black, red @@ -639,8 +647,11 @@ def test_dispose2_palette(tmp_path): # Center remains red every frame assert rgb_img.getpixel((50, 50)) == circle + # Check that frame transparency wasn't added unnecessarily + assert img._frame_transparency is None -def test_dispose2_diff(tmp_path): + +def test_dispose2_diff(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") # 4 frames: red/blue, red/red, blue/blue, red/blue @@ -682,7 +693,7 @@ def test_dispose2_diff(tmp_path): assert rgb_img.getpixel((1, 1)) == (255, 255, 255, 0) -def test_dispose2_background(tmp_path): +def test_dispose2_background(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") im_list = [] @@ -708,7 +719,7 @@ def test_dispose2_background(tmp_path): assert im.getpixel((0, 0)) == (255, 0, 0) -def test_dispose2_background_frame(tmp_path): +def test_dispose2_background_frame(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") im_list = [Image.new("RGBA", (1, 20))] @@ -726,7 +737,26 @@ def test_dispose2_background_frame(tmp_path): assert im.n_frames == 3 -def test_transparency_in_second_frame(tmp_path): +def test_dispose2_previous_frame(tmp_path: Path) -> None: + out = str(tmp_path / "temp.gif") + + im = Image.new("P", (100, 100)) + im.info["transparency"] = 0 + d = ImageDraw.Draw(im) + d.rectangle([(0, 0), (100, 50)], 1) + im.putpalette((0, 0, 0, 255, 0, 0)) + + im2 = Image.new("P", (100, 100)) + im2.putpalette((0, 0, 0)) + + im.save(out, save_all=True, append_images=[im2], disposal=[0, 2]) + + with Image.open(out) as im: + im.seek(1) + assert im.getpixel((0, 0)) == (0, 0, 0, 255) + + +def test_transparency_in_second_frame(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") with Image.open("Tests/images/different_transparency.gif") as im: assert im.info["transparency"] == 0 @@ -746,7 +776,7 @@ def test_transparency_in_second_frame(tmp_path): ) -def test_no_transparency_in_second_frame(): +def test_no_transparency_in_second_frame() -> None: with Image.open("Tests/images/iss634.gif") as img: # Seek to the second frame img.seek(img.tell() + 1) @@ -756,7 +786,7 @@ def test_no_transparency_in_second_frame(): assert img.histogram()[255] == 0 -def test_remapped_transparency(tmp_path): +def test_remapped_transparency(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") im = Image.new("P", (1, 2)) @@ -772,7 +802,7 @@ def test_remapped_transparency(tmp_path): assert reloaded.info["transparency"] == reloaded.getpixel((0, 1)) -def test_duration(tmp_path): +def test_duration(tmp_path: Path) -> None: duration = 1000 out = str(tmp_path / "temp.gif") @@ -786,7 +816,7 @@ def test_duration(tmp_path): assert reread.info["duration"] == duration -def test_multiple_duration(tmp_path): +def test_multiple_duration(tmp_path: Path) -> None: duration_list = [1000, 2000, 3000] out = str(tmp_path / "temp.gif") @@ -821,7 +851,7 @@ def test_multiple_duration(tmp_path): pass -def test_roundtrip_info_duration(tmp_path): +def test_roundtrip_info_duration(tmp_path: Path) -> None: duration_list = [100, 500, 500] out = str(tmp_path / "temp.gif") @@ -838,7 +868,7 @@ def test_roundtrip_info_duration(tmp_path): ] == duration_list -def test_roundtrip_info_duration_combined(tmp_path): +def test_roundtrip_info_duration_combined(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") with Image.open("Tests/images/duplicate_frame.gif") as im: assert [frame.info["duration"] for frame in ImageSequence.Iterator(im)] == [ @@ -854,7 +884,7 @@ def test_roundtrip_info_duration_combined(tmp_path): ] == [1000, 2000] -def test_identical_frames(tmp_path): +def test_identical_frames(tmp_path: Path) -> None: duration_list = [1000, 1500, 2000, 4000] out = str(tmp_path / "temp.gif") @@ -887,7 +917,9 @@ def test_identical_frames(tmp_path): 1500, ), ) -def test_identical_frames_to_single_frame(duration, tmp_path): +def test_identical_frames_to_single_frame( + duration: int | list[int], tmp_path: Path +) -> None: out = str(tmp_path / "temp.gif") im_list = [ Image.new("L", (100, 100), "#000"), @@ -904,7 +936,7 @@ def test_identical_frames_to_single_frame(duration, tmp_path): assert reread.info["duration"] == 4500 -def test_loop_none(tmp_path): +def test_loop_none(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") im = Image.new("L", (100, 100), "#000") im.save(out, loop=None) @@ -912,7 +944,7 @@ def test_loop_none(tmp_path): assert "loop" not in reread.info -def test_number_of_loops(tmp_path): +def test_number_of_loops(tmp_path: Path) -> None: number_of_loops = 2 out = str(tmp_path / "temp.gif") @@ -930,7 +962,7 @@ def test_number_of_loops(tmp_path): assert im.info["loop"] == 2 -def test_background(tmp_path): +def test_background(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") im = Image.new("L", (100, 100), "#000") im.info["background"] = 1 @@ -939,7 +971,7 @@ def test_background(tmp_path): assert reread.info["background"] == im.info["background"] -def test_webp_background(tmp_path): +def test_webp_background(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") # Test opaque WebP background @@ -954,7 +986,7 @@ def test_webp_background(tmp_path): im.save(out) -def test_comment(tmp_path): +def test_comment(tmp_path: Path) -> None: with Image.open(TEST_GIF) as im: assert im.info["comment"] == b"File written by Adobe Photoshop\xa8 4.0" @@ -974,7 +1006,7 @@ def test_comment(tmp_path): assert reread.info["version"] == b"GIF89a" -def test_comment_over_255(tmp_path): +def test_comment_over_255(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") im = Image.new("L", (100, 100), "#000") comment = b"Test comment text" @@ -989,18 +1021,18 @@ def test_comment_over_255(tmp_path): assert reread.info["version"] == b"GIF89a" -def test_zero_comment_subblocks(): +def test_zero_comment_subblocks() -> None: with Image.open("Tests/images/hopper_zero_comment_subblocks.gif") as im: assert_image_equal_tofile(im, TEST_GIF) -def test_read_multiple_comment_blocks(): +def test_read_multiple_comment_blocks() -> None: with Image.open("Tests/images/multiple_comments.gif") as im: # Multiple comment blocks in a frame are separated not concatenated assert im.info["comment"] == b"Test comment 1\nTest comment 2" -def test_empty_string_comment(tmp_path): +def test_empty_string_comment(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") with Image.open("Tests/images/chi.gif") as im: assert "comment" in im.info @@ -1013,7 +1045,7 @@ def test_empty_string_comment(tmp_path): assert "comment" not in frame.info -def test_retain_comment_in_subsequent_frames(tmp_path): +def test_retain_comment_in_subsequent_frames(tmp_path: Path) -> None: # Test that a comment block at the beginning is kept with Image.open("Tests/images/chi.gif") as im: for frame in ImageSequence.Iterator(im): @@ -1044,10 +1076,10 @@ def test_retain_comment_in_subsequent_frames(tmp_path): assert frame.info["comment"] == b"Test" -def test_version(tmp_path): +def test_version(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") - def assert_version_after_save(im, version): + def assert_version_after_save(im: Image.Image, version: bytes) -> None: im.save(out) with Image.open(out) as reread: assert reread.info["version"] == version @@ -1074,7 +1106,7 @@ def test_version(tmp_path): assert_version_after_save(im, b"GIF87a") -def test_append_images(tmp_path): +def test_append_images(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") # Test appending single frame images @@ -1086,7 +1118,7 @@ def test_append_images(tmp_path): assert reread.n_frames == 3 # Tests appending using a generator - def im_generator(ims): + def im_generator(ims: list[Image.Image]) -> Generator[Image.Image, None, None]: yield from ims im.save(out, save_all=True, append_images=im_generator(ims)) @@ -1103,7 +1135,22 @@ def test_append_images(tmp_path): assert reread.n_frames == 10 -def test_transparent_optimize(tmp_path): +def test_append_different_size_image(tmp_path: Path) -> None: + out = str(tmp_path / "temp.gif") + + im = Image.new("RGB", (100, 100)) + bigger_im = Image.new("RGB", (200, 200), "#f00") + + im.save(out, save_all=True, append_images=[bigger_im]) + + with Image.open(out) as reread: + assert reread.size == (100, 100) + + reread.seek(1) + assert reread.size == (100, 100) + + +def test_transparent_optimize(tmp_path: Path) -> None: # From issue #2195, if the transparent color is incorrectly optimized out, GIF loses # transparency. # Need a palette that isn't using the 0 color, @@ -1123,7 +1170,7 @@ def test_transparent_optimize(tmp_path): assert reloaded.info["transparency"] == reloaded.getpixel((252, 0)) -def test_removed_transparency(tmp_path): +def test_removed_transparency(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") im = Image.new("RGB", (256, 1)) @@ -1138,7 +1185,7 @@ def test_removed_transparency(tmp_path): assert "transparency" not in reloaded.info -def test_rgb_transparency(tmp_path): +def test_rgb_transparency(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") # Single frame @@ -1160,7 +1207,7 @@ def test_rgb_transparency(tmp_path): assert "transparency" not in reloaded.info -def test_rgba_transparency(tmp_path): +def test_rgba_transparency(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") im = hopper("P") @@ -1171,13 +1218,13 @@ def test_rgba_transparency(tmp_path): assert_image_equal(hopper("P").convert("RGB"), reloaded) -def test_background_outside_palettte(tmp_path): +def test_background_outside_palettte(tmp_path: Path) -> None: with Image.open("Tests/images/background_outside_palette.gif") as im: im.seek(1) assert im.info["background"] == 255 -def test_bbox(tmp_path): +def test_bbox(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") im = Image.new("RGB", (100, 100), "#fff") @@ -1188,7 +1235,7 @@ def test_bbox(tmp_path): assert reread.n_frames == 2 -def test_bbox_alpha(tmp_path): +def test_bbox_alpha(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") im = Image.new("RGBA", (1, 2), (255, 0, 0, 255)) @@ -1200,7 +1247,7 @@ def test_bbox_alpha(tmp_path): assert reread.n_frames == 2 -def test_palette_save_L(tmp_path): +def test_palette_save_L(tmp_path: Path) -> None: # Generate an L mode image with a separate palette im = hopper("P") @@ -1214,7 +1261,7 @@ def test_palette_save_L(tmp_path): assert_image_equal(reloaded.convert("RGB"), im.convert("RGB")) -def test_palette_save_P(tmp_path): +def test_palette_save_P(tmp_path: Path) -> None: im = Image.new("P", (1, 2)) im.putpixel((0, 1), 1) @@ -1228,7 +1275,7 @@ def test_palette_save_P(tmp_path): assert reloaded_rgb.getpixel((0, 1)) == (4, 5, 6) -def test_palette_save_duplicate_entries(tmp_path): +def test_palette_save_duplicate_entries(tmp_path: Path) -> None: im = Image.new("P", (1, 2)) im.putpixel((0, 1), 1) @@ -1241,7 +1288,7 @@ def test_palette_save_duplicate_entries(tmp_path): assert reloaded.convert("RGB").getpixel((0, 1)) == (0, 0, 0) -def test_palette_save_all_P(tmp_path): +def test_palette_save_all_P(tmp_path: Path) -> None: frames = [] colors = ((255, 0, 0), (0, 255, 0)) for color in colors: @@ -1264,7 +1311,7 @@ def test_palette_save_all_P(tmp_path): assert im.palette.palette == im.global_palette.palette -def test_palette_save_ImagePalette(tmp_path): +def test_palette_save_ImagePalette(tmp_path: Path) -> None: # Pass in a different palette, as an ImagePalette.ImagePalette # effectively the same as test_palette_save_P @@ -1279,7 +1326,7 @@ def test_palette_save_ImagePalette(tmp_path): assert_image_equal(reloaded.convert("RGB"), im.convert("RGB")) -def test_save_I(tmp_path): +def test_save_I(tmp_path: Path) -> None: # Test saving something that would trigger the auto-convert to 'L' im = hopper("I") @@ -1291,7 +1338,7 @@ def test_save_I(tmp_path): assert_image_equal(reloaded.convert("L"), im.convert("L")) -def test_getdata(): +def test_getdata() -> None: # Test getheader/getdata against legacy values. # Create a 'P' image with holes in the palette. im = Image._wedge().resize((16, 16), Image.Resampling.NEAREST) @@ -1319,7 +1366,7 @@ def test_getdata(): GifImagePlugin._FORCE_OPTIMIZE = False -def test_lzw_bits(): +def test_lzw_bits() -> None: # see https://github.com/python-pillow/Pillow/issues/2811 with Image.open("Tests/images/issue_2811.gif") as im: assert im.tile[0][3][0] == 11 # LZW bits @@ -1327,7 +1374,7 @@ def test_lzw_bits(): im.load() -def test_extents(): +def test_extents() -> None: with Image.open("Tests/images/test_extents.gif") as im: assert im.size == (100, 100) @@ -1339,7 +1386,7 @@ def test_extents(): assert im.size == (150, 150) -def test_missing_background(): +def test_missing_background() -> None: # The Global Color Table Flag isn't set, so there is no background color index, # but the disposal method is "Restore to background color" with Image.open("Tests/images/missing_background.gif") as im: @@ -1347,7 +1394,7 @@ def test_missing_background(): assert_image_equal_tofile(im, "Tests/images/missing_background_first_frame.png") -def test_saving_rgba(tmp_path): +def test_saving_rgba(tmp_path: Path) -> None: out = str(tmp_path / "temp.gif") with Image.open("Tests/images/transparent.png") as im: im.save(out) diff --git a/Tests/test_file_gimpgradient.py b/Tests/test_file_gimpgradient.py index d5be46dc3..006ee952d 100644 --- a/Tests/test_file_gimpgradient.py +++ b/Tests/test_file_gimpgradient.py @@ -1,8 +1,9 @@ from __future__ import annotations + from PIL import GimpGradientFile, ImagePalette -def test_linear_pos_le_middle(): +def test_linear_pos_le_middle() -> None: # Arrange middle = 0.5 pos = 0.25 @@ -14,7 +15,7 @@ def test_linear_pos_le_middle(): assert ret == 0.25 -def test_linear_pos_le_small_middle(): +def test_linear_pos_le_small_middle() -> None: # Arrange middle = 1e-11 pos = 1e-12 @@ -26,7 +27,7 @@ def test_linear_pos_le_small_middle(): assert ret == 0.0 -def test_linear_pos_gt_middle(): +def test_linear_pos_gt_middle() -> None: # Arrange middle = 0.5 pos = 0.75 @@ -38,7 +39,7 @@ def test_linear_pos_gt_middle(): assert ret == 0.75 -def test_linear_pos_gt_small_middle(): +def test_linear_pos_gt_small_middle() -> None: # Arrange middle = 1 - 1e-11 pos = 1 - 1e-12 @@ -50,7 +51,7 @@ def test_linear_pos_gt_small_middle(): assert ret == 1.0 -def test_curved(): +def test_curved() -> None: # Arrange middle = 0.5 pos = 0.75 @@ -62,7 +63,7 @@ def test_curved(): assert ret == 0.75 -def test_sine(): +def test_sine() -> None: # Arrange middle = 0.5 pos = 0.75 @@ -74,7 +75,7 @@ def test_sine(): assert ret == 0.8535533905932737 -def test_sphere_increasing(): +def test_sphere_increasing() -> None: # Arrange middle = 0.5 pos = 0.75 @@ -86,7 +87,7 @@ def test_sphere_increasing(): assert round(abs(ret - 0.9682458365518543), 7) == 0 -def test_sphere_decreasing(): +def test_sphere_decreasing() -> None: # Arrange middle = 0.5 pos = 0.75 @@ -98,7 +99,7 @@ def test_sphere_decreasing(): assert ret == 0.3385621722338523 -def test_load_via_imagepalette(): +def test_load_via_imagepalette() -> None: # Arrange test_file = "Tests/images/gimp_gradient.ggr" @@ -111,7 +112,7 @@ def test_load_via_imagepalette(): assert palette[1] == "RGBA" -def test_load_1_3_via_imagepalette(): +def test_load_1_3_via_imagepalette() -> None: # Arrange # GIMP 1.3 gradient files contain a name field test_file = "Tests/images/gimp_gradient_with_name.ggr" diff --git a/Tests/test_file_gimppalette.py b/Tests/test_file_gimppalette.py index 775d3b7cd..e8d5f1705 100644 --- a/Tests/test_file_gimppalette.py +++ b/Tests/test_file_gimppalette.py @@ -1,10 +1,11 @@ from __future__ import annotations + import pytest from PIL.GimpPaletteFile import GimpPaletteFile -def test_sanity(): +def test_sanity() -> None: with open("Tests/images/test.gpl", "rb") as fp: GimpPaletteFile(fp) @@ -21,7 +22,7 @@ def test_sanity(): GimpPaletteFile(fp) -def test_get_palette(): +def test_get_palette() -> None: # Arrange with open("Tests/images/custom_gimp_palette.gpl", "rb") as fp: palette_file = GimpPaletteFile(fp) diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py index d962e85a4..096a5b88b 100644 --- a/Tests/test_file_gribstub.py +++ b/Tests/test_file_gribstub.py @@ -1,4 +1,8 @@ from __future__ import annotations + +from pathlib import Path +from typing import IO + import pytest from PIL import GribStubImagePlugin, Image @@ -8,7 +12,7 @@ from .helper import hopper TEST_FILE = "Tests/images/WAlaska.wind.7days.grb" -def test_open(): +def test_open() -> None: # Act with Image.open(TEST_FILE) as im: # Assert @@ -19,7 +23,7 @@ def test_open(): assert im.size == (1, 1) -def test_invalid_file(): +def test_invalid_file() -> None: # Arrange invalid_file = "Tests/images/flower.jpg" @@ -28,7 +32,7 @@ def test_invalid_file(): GribStubImagePlugin.GribStubImageFile(invalid_file) -def test_load(): +def test_load() -> None: # Arrange with Image.open(TEST_FILE) as im: # Act / Assert: stub cannot load without an implemented handler @@ -36,7 +40,7 @@ def test_load(): im.load() -def test_save(tmp_path): +def test_save(tmp_path: Path) -> None: # Arrange im = hopper() tmpfile = str(tmp_path / "temp.grib") @@ -46,21 +50,21 @@ def test_save(tmp_path): im.save(tmpfile) -def test_handler(tmp_path): +def test_handler(tmp_path: Path) -> None: class TestHandler: opened = False loaded = False saved = False - def open(self, im): + def open(self, im: Image.Image) -> None: self.opened = True - def load(self, im): + def load(self, im: Image.Image) -> Image.Image: self.loaded = True im.fp.close() return Image.new("RGB", (1, 1)) - def save(self, im, fp, filename): + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.saved = True handler = TestHandler() diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index 9c776b712..f871e2eff 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -1,4 +1,8 @@ from __future__ import annotations + +from pathlib import Path +from typing import IO + import pytest from PIL import Hdf5StubImagePlugin, Image @@ -6,7 +10,7 @@ from PIL import Hdf5StubImagePlugin, Image TEST_FILE = "Tests/images/hdf5.h5" -def test_open(): +def test_open() -> None: # Act with Image.open(TEST_FILE) as im: # Assert @@ -17,7 +21,7 @@ def test_open(): assert im.size == (1, 1) -def test_invalid_file(): +def test_invalid_file() -> None: # Arrange invalid_file = "Tests/images/flower.jpg" @@ -26,7 +30,7 @@ def test_invalid_file(): Hdf5StubImagePlugin.HDF5StubImageFile(invalid_file) -def test_load(): +def test_load() -> None: # Arrange with Image.open(TEST_FILE) as im: # Act / Assert: stub cannot load without an implemented handler @@ -34,7 +38,7 @@ def test_load(): im.load() -def test_save(): +def test_save() -> None: # Arrange with Image.open(TEST_FILE) as im: dummy_fp = None @@ -47,21 +51,21 @@ def test_save(): Hdf5StubImagePlugin._save(im, dummy_fp, dummy_filename) -def test_handler(tmp_path): +def test_handler(tmp_path: Path) -> None: class TestHandler: opened = False loaded = False saved = False - def open(self, im): + def open(self, im: Image.Image) -> None: self.opened = True - def load(self, im): + def load(self, im: Image.Image) -> Image.Image: self.loaded = True im.fp.close() return Image.new("RGB", (1, 1)) - def save(self, im, fp, filename): + def save(self, im: Image.Image, fp: IO[bytes], filename: str) -> None: self.saved = True handler = TestHandler() diff --git a/Tests/test_file_icns.py b/Tests/test_file_icns.py index c62fffc5b..488984aef 100644 --- a/Tests/test_file_icns.py +++ b/Tests/test_file_icns.py @@ -1,7 +1,9 @@ from __future__ import annotations + import io import os import warnings +from pathlib import Path import pytest @@ -13,7 +15,7 @@ from .helper import assert_image_equal, assert_image_similar_tofile, skip_unless TEST_FILE = "Tests/images/pillow.icns" -def test_sanity(): +def test_sanity() -> None: # Loading this icon by default should result in the largest size # (512x512@2x) being loaded with Image.open(TEST_FILE) as im: @@ -26,7 +28,7 @@ def test_sanity(): assert im.format == "ICNS" -def test_load(): +def test_load() -> None: with Image.open(TEST_FILE) as im: assert im.load()[0, 0] == (0, 0, 0, 0) @@ -34,7 +36,7 @@ def test_load(): assert im.load()[0, 0] == (0, 0, 0, 0) -def test_save(tmp_path): +def test_save(tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.icns") with Image.open(TEST_FILE) as im: @@ -51,7 +53,7 @@ def test_save(tmp_path): assert _binary.i32be(fp.read(4)) == file_length -def test_save_append_images(tmp_path): +def test_save_append_images(tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.icns") provided_im = Image.new("RGBA", (32, 32), (255, 0, 0, 128)) @@ -66,7 +68,7 @@ def test_save_append_images(tmp_path): assert_image_equal(reread, provided_im) -def test_save_fp(): +def test_save_fp() -> None: fp = io.BytesIO() with Image.open(TEST_FILE) as im: @@ -78,7 +80,7 @@ def test_save_fp(): assert reread.format == "ICNS" -def test_sizes(): +def test_sizes() -> None: # Check that we can load all of the sizes, and that the final pixel # dimensions are as expected with Image.open(TEST_FILE) as im: @@ -95,7 +97,7 @@ def test_sizes(): im.size = (1, 1) -def test_older_icon(): +def test_older_icon() -> None: # This icon was made with Icon Composer rather than iconutil; it still # uses PNG rather than JP2, however (since it was made on 10.9). with Image.open("Tests/images/pillow2.icns") as im: @@ -110,7 +112,7 @@ def test_older_icon(): @skip_unless_feature("jpg_2000") -def test_jp2_icon(): +def test_jp2_icon() -> None: # This icon uses JPEG 2000 images instead of the PNG images. # The advantage of doing this is that OS X 10.5 supports JPEG 2000 # but not PNG; some commercial software therefore does just this. @@ -126,7 +128,7 @@ def test_jp2_icon(): assert im2.size == (wr, hr) -def test_getimage(): +def test_getimage() -> None: with open(TEST_FILE, "rb") as fp: icns_file = IcnsImagePlugin.IcnsFile(fp) @@ -139,14 +141,14 @@ def test_getimage(): assert im.size == (512, 512) -def test_not_an_icns_file(): +def test_not_an_icns_file() -> None: with io.BytesIO(b"invalid\n") as fp: with pytest.raises(SyntaxError): IcnsImagePlugin.IcnsFile(fp) @skip_unless_feature("jpg_2000") -def test_icns_decompression_bomb(): +def test_icns_decompression_bomb() -> None: with Image.open( "Tests/images/oom-8ed3316a4109213ca96fb8a256a0bfefdece1461.icns" ) as im: diff --git a/Tests/test_file_ico.py b/Tests/test_file_ico.py index de9fa353a..fa8c11d5a 100644 --- a/Tests/test_file_ico.py +++ b/Tests/test_file_ico.py @@ -1,6 +1,8 @@ from __future__ import annotations + import io import os +from pathlib import Path import pytest @@ -11,7 +13,7 @@ from .helper import assert_image_equal, assert_image_equal_tofile, hopper TEST_ICO_FILE = "Tests/images/hopper.ico" -def test_sanity(): +def test_sanity() -> None: with Image.open(TEST_ICO_FILE) as im: im.load() assert im.mode == "RGBA" @@ -20,29 +22,40 @@ def test_sanity(): assert im.get_format_mimetype() == "image/x-icon" -def test_load(): +def test_load() -> None: with Image.open(TEST_ICO_FILE) as im: assert im.load()[0, 0] == (1, 1, 9, 255) -def test_mask(): +def test_mask() -> None: with Image.open("Tests/images/hopper_mask.ico") as im: assert_image_equal_tofile(im, "Tests/images/hopper_mask.png") -def test_black_and_white(): +def test_black_and_white() -> None: with Image.open("Tests/images/black_and_white.ico") as im: assert im.mode == "RGBA" assert im.size == (16, 16) -def test_invalid_file(): +def test_palette(tmp_path: Path) -> None: + temp_file = str(tmp_path / "temp.ico") + + im = Image.new("P", (16, 16)) + im.save(temp_file) + + with Image.open(temp_file) as reloaded: + assert reloaded.mode == "P" + assert reloaded.palette is not None + + +def test_invalid_file() -> None: with open("Tests/images/flower.jpg", "rb") as fp: with pytest.raises(SyntaxError): IcoImagePlugin.IcoImageFile(fp) -def test_save_to_bytes(): +def test_save_to_bytes() -> None: output = io.BytesIO() im = hopper() im.save(output, "ico", sizes=[(32, 32), (64, 64)]) @@ -72,7 +85,7 @@ def test_save_to_bytes(): ) -def test_getpixel(tmp_path): +def test_getpixel(tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.ico") im = hopper() @@ -85,7 +98,7 @@ def test_getpixel(tmp_path): assert reloaded.getpixel((0, 0)) == (18, 20, 62) -def test_no_duplicates(tmp_path): +def test_no_duplicates(tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.ico") temp_file2 = str(tmp_path / "temp2.ico") @@ -99,7 +112,7 @@ def test_no_duplicates(tmp_path): assert os.path.getsize(temp_file) == os.path.getsize(temp_file2) -def test_different_bit_depths(tmp_path): +def test_different_bit_depths(tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.ico") temp_file2 = str(tmp_path / "temp2.ico") @@ -133,7 +146,7 @@ def test_different_bit_depths(tmp_path): @pytest.mark.parametrize("mode", ("1", "L", "P", "RGB", "RGBA")) -def test_save_to_bytes_bmp(mode): +def test_save_to_bytes_bmp(mode: str) -> None: output = io.BytesIO() im = hopper(mode) im.save(output, "ico", bitmap_format="bmp", sizes=[(32, 32), (64, 64)]) @@ -161,13 +174,13 @@ def test_save_to_bytes_bmp(mode): assert_image_equal(reloaded, im) -def test_incorrect_size(): +def test_incorrect_size() -> None: with Image.open(TEST_ICO_FILE) as im: with pytest.raises(ValueError): im.size = (1, 1) -def test_save_256x256(tmp_path): +def test_save_256x256(tmp_path: Path) -> None: """Issue #2264 https://github.com/python-pillow/Pillow/issues/2264""" # Arrange with Image.open("Tests/images/hopper_256x256.ico") as im: @@ -180,7 +193,7 @@ def test_save_256x256(tmp_path): assert im_saved.size == (256, 256) -def test_only_save_relevant_sizes(tmp_path): +def test_only_save_relevant_sizes(tmp_path: Path) -> None: """Issue #2266 https://github.com/python-pillow/Pillow/issues/2266 Should save in 16x16, 24x24, 32x32, 48x48 sizes and not in 16x16, 24x24, 32x32, 48x48, 48x48, 48x48, 48x48 sizes @@ -196,7 +209,7 @@ def test_only_save_relevant_sizes(tmp_path): assert im_saved.info["sizes"] == {(16, 16), (24, 24), (32, 32), (48, 48)} -def test_save_append_images(tmp_path): +def test_save_append_images(tmp_path: Path) -> None: # append_images should be used for scaled down versions of the image im = hopper("RGBA") provided_im = Image.new("RGBA", (32, 32), (255, 0, 0)) @@ -210,7 +223,7 @@ def test_save_append_images(tmp_path): assert_image_equal(reread, provided_im) -def test_unexpected_size(): +def test_unexpected_size() -> None: # This image has been manually hexedited to state that it is 16x32 # while the image within is still 16x16 with pytest.warns(UserWarning): @@ -218,7 +231,7 @@ def test_unexpected_size(): assert im.size == (16, 16) -def test_draw_reloaded(tmp_path): +def test_draw_reloaded(tmp_path: Path) -> None: with Image.open(TEST_ICO_FILE) as im: outfile = str(tmp_path / "temp_saved_hopper_draw.ico") diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py index 0cb26d06a..036965bf5 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -1,6 +1,8 @@ from __future__ import annotations + import filecmp import warnings +from pathlib import Path import pytest @@ -12,7 +14,7 @@ from .helper import assert_image_equal_tofile, hopper, is_pypy TEST_IM = "Tests/images/hopper.im" -def test_sanity(): +def test_sanity() -> None: with Image.open(TEST_IM) as im: im.load() assert im.mode == "RGB" @@ -20,7 +22,7 @@ def test_sanity(): assert im.format == "IM" -def test_name_limit(tmp_path): +def test_name_limit(tmp_path: Path) -> None: out = str(tmp_path / ("name_limit_test" * 7 + ".im")) with Image.open(TEST_IM) as im: im.save(out) @@ -28,8 +30,8 @@ def test_name_limit(tmp_path): @pytest.mark.skipif(is_pypy(), reason="Requires CPython") -def test_unclosed_file(): - def open(): +def test_unclosed_file() -> None: + def open() -> None: im = Image.open(TEST_IM) im.load() @@ -37,20 +39,20 @@ def test_unclosed_file(): open() -def test_closed_file(): +def test_closed_file() -> None: with warnings.catch_warnings(): im = Image.open(TEST_IM) im.load() im.close() -def test_context_manager(): +def test_context_manager() -> None: with warnings.catch_warnings(): with Image.open(TEST_IM) as im: im.load() -def test_tell(): +def test_tell() -> None: # Arrange with Image.open(TEST_IM) as im: # Act @@ -60,13 +62,13 @@ def test_tell(): assert frame == 0 -def test_n_frames(): +def test_n_frames() -> None: with Image.open(TEST_IM) as im: assert im.n_frames == 1 assert not im.is_animated -def test_eoferror(): +def test_eoferror() -> None: with Image.open(TEST_IM) as im: n_frames = im.n_frames @@ -80,14 +82,14 @@ def test_eoferror(): @pytest.mark.parametrize("mode", ("RGB", "P", "PA")) -def test_roundtrip(mode, tmp_path): +def test_roundtrip(mode: str, tmp_path: Path) -> None: out = str(tmp_path / "temp.im") im = hopper(mode) im.save(out) assert_image_equal_tofile(im, out) -def test_small_palette(tmp_path): +def test_small_palette(tmp_path: Path) -> None: im = Image.new("P", (1, 1)) colors = [0, 1, 2] im.putpalette(colors) @@ -99,19 +101,19 @@ def test_small_palette(tmp_path): assert reloaded.getpalette() == colors + [0] * 765 -def test_save_unsupported_mode(tmp_path): +def test_save_unsupported_mode(tmp_path: Path) -> None: out = str(tmp_path / "temp.im") im = hopper("HSV") with pytest.raises(ValueError): im.save(out) -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): ImImagePlugin.ImImageFile(invalid_file) -def test_number(): +def test_number() -> None: assert ImImagePlugin.number("1.2") == 1.2 diff --git a/Tests/test_file_imt.py b/Tests/test_file_imt.py index 3db488558..6957dfa0a 100644 --- a/Tests/test_file_imt.py +++ b/Tests/test_file_imt.py @@ -1,4 +1,5 @@ from __future__ import annotations + import io import pytest @@ -8,13 +9,13 @@ from PIL import Image, ImtImagePlugin from .helper import assert_image_equal_tofile -def test_sanity(): +def test_sanity() -> None: with Image.open("Tests/images/bw_gradient.imt") as im: assert_image_equal_tofile(im, "Tests/images/bw_gradient.png") @pytest.mark.parametrize("data", (b"\n", b"\n-", b"width 1\n")) -def test_invalid_file(data): +def test_invalid_file(data: bytes) -> None: with io.BytesIO(data) as fp: with pytest.raises(SyntaxError): ImtImagePlugin.ImtImageFile(fp) diff --git a/Tests/test_file_iptc.py b/Tests/test_file_iptc.py index d0ecde393..88c30d468 100644 --- a/Tests/test_file_iptc.py +++ b/Tests/test_file_iptc.py @@ -1,4 +1,5 @@ from __future__ import annotations + import sys from io import BytesIO, StringIO @@ -6,12 +7,24 @@ import pytest from PIL import Image, IptcImagePlugin -from .helper import hopper +from .helper import assert_image_equal, hopper TEST_FILE = "Tests/images/iptc.jpg" -def test_getiptcinfo_jpg_none(): +def test_open() -> None: + expected = Image.new("L", (1, 1)) + + f = BytesIO( + b"\x1c\x03<\x00\x02\x01\x00\x1c\x03x\x00\x01\x01\x1c\x03\x14\x00\x01\x01" + b"\x1c\x03\x1e\x00\x01\x01\x1c\x08\n\x00\x01\x00" + ) + with Image.open(f) as im: + assert im.tile == [("iptc", (0, 0, 1, 1), 25, "raw")] + assert_image_equal(im, expected) + + +def test_getiptcinfo_jpg_none() -> None: # Arrange with hopper() as im: # Act @@ -21,7 +34,7 @@ def test_getiptcinfo_jpg_none(): assert iptc is None -def test_getiptcinfo_jpg_found(): +def test_getiptcinfo_jpg_found() -> None: # Arrange with Image.open(TEST_FILE) as im: # Act @@ -33,7 +46,7 @@ def test_getiptcinfo_jpg_found(): assert iptc[(2, 101)] == b"Hungary" -def test_getiptcinfo_fotostation(): +def test_getiptcinfo_fotostation() -> None: # Arrange with open(TEST_FILE, "rb") as fp: data = bytearray(fp.read()) @@ -50,7 +63,7 @@ def test_getiptcinfo_fotostation(): pytest.fail("FotoStation tag not found") -def test_getiptcinfo_zero_padding(): +def test_getiptcinfo_zero_padding() -> None: # Arrange with Image.open(TEST_FILE) as im: im.info["photoshop"][0x0404] += b"\x00\x00\x00" @@ -63,7 +76,7 @@ def test_getiptcinfo_zero_padding(): assert len(iptc) == 3 -def test_getiptcinfo_tiff_none(): +def test_getiptcinfo_tiff_none() -> None: # Arrange with Image.open("Tests/images/hopper.tif") as im: # Act @@ -73,29 +86,33 @@ def test_getiptcinfo_tiff_none(): assert iptc is None -def test_i(): +def test_i() -> None: # Arrange c = b"a" # Act - ret = IptcImagePlugin.i(c) + with pytest.warns(DeprecationWarning): + ret = IptcImagePlugin.i(c) # Assert assert ret == 97 -def test_dump(): +def test_dump(monkeypatch: pytest.MonkeyPatch) -> None: # Arrange c = b"abc" # Temporarily redirect stdout - old_stdout = sys.stdout - sys.stdout = mystdout = StringIO() + mystdout = StringIO() + monkeypatch.setattr(sys, "stdout", mystdout) # Act - IptcImagePlugin.dump(c) - - # Reset stdout - sys.stdout = old_stdout + with pytest.warns(DeprecationWarning): + IptcImagePlugin.dump(c) # Assert assert mystdout.getvalue() == "61 62 63 \n" + + +def test_pad_deprecation() -> None: + with pytest.warns(DeprecationWarning): + assert IptcImagePlugin.PAD == b"\0\0\0\0" diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index ffaea6296..eeebb2d12 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -1,8 +1,12 @@ from __future__ import annotations + import os import re import warnings from io import BytesIO +from pathlib import Path +from types import ModuleType +from typing import Any, cast import pytest @@ -30,6 +34,7 @@ from .helper import ( skip_unless_feature, ) +ElementTree: ModuleType | None try: from defusedxml import ElementTree except ImportError: @@ -40,16 +45,22 @@ TEST_FILE = "Tests/images/hopper.jpg" @skip_unless_feature("jpg") class TestFileJpeg: - def roundtrip(self, im, **options): + def roundtrip_with_bytes( + self, im: Image.Image, **options: Any + ) -> tuple[JpegImagePlugin.JpegImageFile, int]: out = BytesIO() im.save(out, "JPEG", **options) test_bytes = out.tell() out.seek(0) - im = Image.open(out) - im.bytes = test_bytes # for testing only - return im + reloaded = cast(JpegImagePlugin.JpegImageFile, Image.open(out)) + return reloaded, test_bytes - def gen_random_image(self, size, mode="RGB"): + def roundtrip( + self, im: Image.Image, **options: Any + ) -> JpegImagePlugin.JpegImageFile: + return self.roundtrip_with_bytes(im, **options)[0] + + def gen_random_image(self, size: tuple[int, int], mode: str = "RGB") -> Image.Image: """Generates a very hard to compress file :param size: tuple :param mode: optional image mode @@ -57,7 +68,7 @@ class TestFileJpeg: """ return Image.frombytes(mode, size, os.urandom(size[0] * size[1] * len(mode))) - def test_sanity(self): + def test_sanity(self) -> None: # internal version number assert re.search(r"\d+\.\d+$", features.version_codec("jpg")) @@ -69,13 +80,13 @@ class TestFileJpeg: assert im.get_format_mimetype() == "image/jpeg" @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) - def test_zero(self, size, tmp_path): + def test_zero(self, size: tuple[int, int], tmp_path: Path) -> None: f = str(tmp_path / "temp.jpg") im = Image.new("RGB", size) with pytest.raises(ValueError): im.save(f) - def test_app(self): + def test_app(self) -> None: # Test APP/COM reader (@PIL135) with Image.open(TEST_FILE) as im: assert im.applist[0] == ("APP0", b"JFIF\x00\x01\x01\x01\x00`\x00`\x00\x00") @@ -88,7 +99,7 @@ class TestFileJpeg: assert im.info["comment"] == b"File written by Adobe Photoshop\xa8 4.0\x00" assert im.app["COM"] == im.info["comment"] - def test_comment_write(self): + def test_comment_write(self) -> None: with Image.open(TEST_FILE) as im: assert im.info["comment"] == b"File written by Adobe Photoshop\xa8 4.0\x00" @@ -106,15 +117,13 @@ class TestFileJpeg: assert "comment" not in reloaded.info # Test that a comment argument overrides the default comment - for comment in ("Test comment text", b"Text comment text"): + for comment in ("Test comment text", b"Test comment text"): out = BytesIO() im.save(out, format="JPEG", comment=comment) with Image.open(out) as reloaded: - if not isinstance(comment, bytes): - comment = comment.encode() - assert reloaded.info["comment"] == comment + assert reloaded.info["comment"] == b"Test comment text" - def test_cmyk(self): + def test_cmyk(self) -> None: # Test CMYK handling. Thanks to Tim and Charlie for test data, # Michael for getting me to look one more time. f = "Tests/images/pil_sample_cmyk.jpg" @@ -142,12 +151,25 @@ class TestFileJpeg: ) assert k > 0.9 + def test_rgb(self) -> None: + def getchannels(im: Image.Image) -> tuple[int, int, int]: + return tuple(v[0] for v in im.layer) + + im = hopper() + im_ycbcr = self.roundtrip(im) + assert getchannels(im_ycbcr) == (1, 2, 3) + assert_image_similar(im, im_ycbcr, 17) + + im_rgb = self.roundtrip(im, keep_rgb=True) + assert getchannels(im_rgb) == (ord("R"), ord("G"), ord("B")) + assert_image_similar(im, im_rgb, 12) + @pytest.mark.parametrize( "test_image_path", [TEST_FILE, "Tests/images/pil_sample_cmyk.jpg"], ) - def test_dpi(self, test_image_path): - def test(xdpi, ydpi=None): + def test_dpi(self, test_image_path: str) -> None: + def test(xdpi: int, ydpi: int | None = None): with Image.open(test_image_path) as im: im = self.roundtrip(im, dpi=(xdpi, ydpi or xdpi)) return im.info.get("dpi") @@ -160,7 +182,7 @@ class TestFileJpeg: @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_icc(self, tmp_path): + def test_icc(self, tmp_path: Path) -> None: # Test ICC support with Image.open("Tests/images/rgb.jpg") as im1: icc_profile = im1.info["icc_profile"] @@ -192,7 +214,7 @@ class TestFileJpeg: ImageFile.MAXBLOCK * 4 + 3, # large block ), ) - def test_icc_big(self, n): + def test_icc_big(self, n: int) -> None: # Make sure that the "extra" support handles large blocks # The ICC APP marker can store 65519 bytes per marker, so # using a 4-byte test code should allow us to detect out of @@ -205,7 +227,7 @@ class TestFileJpeg: @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_large_icc_meta(self, tmp_path): + def test_large_icc_meta(self, tmp_path: Path) -> None: # https://github.com/python-pillow/Pillow/issues/148 # Sometimes the meta data on the icc_profile block is bigger than # Image.MAXBLOCK or the image size. @@ -229,52 +251,52 @@ class TestFileJpeg: 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) - im3 = self.roundtrip(hopper(), optimize=1) + def test_optimize(self) -> None: + im1, im1_bytes = self.roundtrip_with_bytes(hopper()) + im2, im2_bytes = self.roundtrip_with_bytes(hopper(), optimize=0) + im3, im3_bytes = self.roundtrip_with_bytes(hopper(), optimize=1) assert_image_equal(im1, im2) assert_image_equal(im1, im3) - assert im1.bytes >= im2.bytes - assert im1.bytes >= im3.bytes + assert im1_bytes >= im2_bytes + assert im1_bytes >= im3_bytes - def test_optimize_large_buffer(self, tmp_path): + def test_optimize_large_buffer(self, tmp_path: Path) -> None: # https://github.com/python-pillow/Pillow/issues/148 f = str(tmp_path / "temp.jpg") # this requires ~ 1.5x Image.MAXBLOCK im = Image.new("RGB", (4096, 4096), 0xFF3333) im.save(f, format="JPEG", optimize=True) - def test_progressive(self): - im1 = self.roundtrip(hopper()) + def test_progressive(self) -> None: + im1, im1_bytes = self.roundtrip_with_bytes(hopper()) im2 = self.roundtrip(hopper(), progressive=False) - im3 = self.roundtrip(hopper(), progressive=True) + im3, im3_bytes = self.roundtrip_with_bytes(hopper(), progressive=True) assert not im1.info.get("progressive") assert not im2.info.get("progressive") assert im3.info.get("progressive") assert_image_equal(im1, im3) - assert im1.bytes >= im3.bytes + assert im1_bytes >= im3_bytes - def test_progressive_large_buffer(self, tmp_path): + def test_progressive_large_buffer(self, tmp_path: Path) -> None: f = str(tmp_path / "temp.jpg") # this requires ~ 1.5x Image.MAXBLOCK im = Image.new("RGB", (4096, 4096), 0xFF3333) im.save(f, format="JPEG", progressive=True) - def test_progressive_large_buffer_highest_quality(self, tmp_path): + def test_progressive_large_buffer_highest_quality(self, tmp_path: Path) -> None: f = str(tmp_path / "temp.jpg") im = self.gen_random_image((255, 255)) # this requires more bytes than pixels in the image im.save(f, format="JPEG", progressive=True, quality=100) - def test_progressive_cmyk_buffer(self): + def test_progressive_cmyk_buffer(self) -> None: # Issue 2272, quality 90 cmyk image is tripping the large buffer bug. f = BytesIO() im = self.gen_random_image((256, 256), "CMYK") im.save(f, format="JPEG", progressive=True, quality=94) - def test_large_exif(self, tmp_path): + def test_large_exif(self, tmp_path: Path) -> None: # https://github.com/python-pillow/Pillow/issues/148 f = str(tmp_path / "temp.jpg") im = hopper() @@ -283,12 +305,12 @@ class TestFileJpeg: with pytest.raises(ValueError): im.save(f, "JPEG", quality=90, exif=b"1" * 65534) - def test_exif_typeerror(self): + def test_exif_typeerror(self) -> None: with Image.open("Tests/images/exif_typeerror.jpg") as im: # Should not raise a TypeError im._getexif() - def test_exif_gps(self, tmp_path): + def test_exif_gps(self, tmp_path: Path) -> None: expected_exif_gps = { 0: b"\x00\x00\x00\x01", 2: 4294967295, @@ -313,7 +335,7 @@ class TestFileJpeg: exif = reloaded._getexif() assert exif[gps_index] == expected_exif_gps - def test_empty_exif_gps(self): + def test_empty_exif_gps(self) -> None: with Image.open("Tests/images/empty_gps_ifd.jpg") as im: exif = im.getexif() del exif[0x8769] @@ -325,13 +347,14 @@ class TestFileJpeg: assert exif.get_ifd(0x8825) == {} transposed = ImageOps.exif_transpose(im) + assert transposed is not None exif = transposed.getexif() assert exif.get_ifd(0x8825) == {} # Assert that it was transposed assert 0x0112 not in exif - def test_exif_equality(self): + def test_exif_equality(self) -> None: # In 7.2.0, Exif rationals were changed to be read as # TiffImagePlugin.IFDRational. This class had a bug in __eq__, # breaking the self-equality of Exif data @@ -341,7 +364,7 @@ class TestFileJpeg: exifs.append(im._getexif()) assert exifs[0] == exifs[1] - def test_exif_rollback(self): + def test_exif_rollback(self) -> None: # rolling back exif support in 3.1 to pre-3.0 formatting. # expected from 2.9, with b/u qualifiers switched for 3.2 compatibility # this test passes on 2.9 and 3.1, but not 3.0 @@ -376,12 +399,12 @@ class TestFileJpeg: for tag, value in expected_exif.items(): assert value == exif[tag] - def test_exif_gps_typeerror(self): + def test_exif_gps_typeerror(self) -> None: with Image.open("Tests/images/exif_gps_typeerror.jpg") as im: # Should not raise a TypeError im._getexif() - def test_progressive_compat(self): + def test_progressive_compat(self) -> None: im1 = self.roundtrip(hopper()) assert not im1.info.get("progressive") assert not im1.info.get("progression") @@ -402,67 +425,70 @@ class TestFileJpeg: assert im3.info.get("progressive") assert im3.info.get("progression") - def test_quality(self): - im1 = self.roundtrip(hopper()) - im2 = self.roundtrip(hopper(), quality=50) + def test_quality(self) -> None: + im1, im1_bytes = self.roundtrip_with_bytes(hopper()) + im2, im2_bytes = self.roundtrip_with_bytes(hopper(), quality=50) assert_image(im1, im2.mode, im2.size) - assert im1.bytes >= im2.bytes + assert im1_bytes >= im2_bytes - im3 = self.roundtrip(hopper(), quality=0) + im3, im3_bytes = self.roundtrip_with_bytes(hopper(), quality=0) assert_image(im1, im3.mode, im3.size) - assert im2.bytes > im3.bytes + assert im2_bytes > im3_bytes - def test_smooth(self): + def test_smooth(self) -> None: im1 = self.roundtrip(hopper()) im2 = self.roundtrip(hopper(), smooth=100) assert_image(im1, im2.mode, im2.size) - def test_subsampling(self): - def getsampling(im): + def test_subsampling(self) -> None: + def getsampling(im: Image.Image): layer = im.layer return layer[0][1:3] + layer[1][1:3] + layer[2][1:3] # experimental API - im = self.roundtrip(hopper(), subsampling=-1) # default - assert getsampling(im) == (2, 2, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling=0) # 4:4:4 - assert getsampling(im) == (1, 1, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling=1) # 4:2:2 - assert getsampling(im) == (2, 1, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling=2) # 4:2:0 - assert getsampling(im) == (2, 2, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling=3) # default (undefined) - assert getsampling(im) == (2, 2, 1, 1, 1, 1) + for subsampling in (-1, 3): # (default, invalid) + im = self.roundtrip(hopper(), subsampling=subsampling) + assert getsampling(im) == (2, 2, 1, 1, 1, 1) + for subsampling1 in (0, "4:4:4"): + im = self.roundtrip(hopper(), subsampling=subsampling1) + assert getsampling(im) == (1, 1, 1, 1, 1, 1) + for subsampling1 in (1, "4:2:2"): + im = self.roundtrip(hopper(), subsampling=subsampling1) + assert getsampling(im) == (2, 1, 1, 1, 1, 1) + for subsampling1 in (2, "4:2:0", "4:1:1"): + im = self.roundtrip(hopper(), subsampling=subsampling1) + assert getsampling(im) == (2, 2, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling="4:4:4") - assert getsampling(im) == (1, 1, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling="4:2:2") - assert getsampling(im) == (2, 1, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling="4:2:0") - assert getsampling(im) == (2, 2, 1, 1, 1, 1) - im = self.roundtrip(hopper(), subsampling="4:1:1") - assert getsampling(im) == (2, 2, 1, 1, 1, 1) + # RGB colorspace + for subsampling1 in (-1, 0, "4:4:4"): + # "4:4:4" doesn't really make sense for RGB, but the conversion + # to an integer happens at a higher level + im = self.roundtrip(hopper(), keep_rgb=True, subsampling=subsampling1) + assert getsampling(im) == (1, 1, 1, 1, 1, 1) + for subsampling1 in (1, "4:2:2", 2, "4:2:0", 3): + with pytest.raises(OSError): + self.roundtrip(hopper(), keep_rgb=True, subsampling=subsampling1) with pytest.raises(TypeError): self.roundtrip(hopper(), subsampling="1:1:1") - def test_exif(self): + def test_exif(self) -> None: with Image.open("Tests/images/pil_sample_rgb.jpg") as im: info = im._getexif() assert info[305] == "Adobe Photoshop CS Macintosh" - def test_get_child_images(self): + def test_get_child_images(self) -> None: with Image.open("Tests/images/flower.jpg") as im: ims = im.get_child_images() assert len(ims) == 1 assert_image_similar_tofile(ims[0], "Tests/images/flower_thumbnail.png", 2.1) - def test_mp(self): + def test_mp(self) -> None: with Image.open("Tests/images/pil_sample_rgb.jpg") as im: assert im._getmp() is None - def test_quality_keep(self, tmp_path): + def test_quality_keep(self, tmp_path: Path) -> None: # RGB with Image.open("Tests/images/hopper.jpg") as im: f = str(tmp_path / "temp.jpg") @@ -476,13 +502,13 @@ class TestFileJpeg: f = str(tmp_path / "temp.jpg") im.save(f, quality="keep") - def test_junk_jpeg_header(self): + def test_junk_jpeg_header(self) -> None: # https://github.com/python-pillow/Pillow/issues/630 filename = "Tests/images/junk_jpeg_header.jpg" with Image.open(filename): pass - def test_ff00_jpeg_header(self): + def test_ff00_jpeg_header(self) -> None: filename = "Tests/images/jpeg_ff00_header.jpg" with Image.open(filename): pass @@ -490,7 +516,7 @@ class TestFileJpeg: @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_truncated_jpeg_should_read_all_the_data(self): + def test_truncated_jpeg_should_read_all_the_data(self) -> None: filename = "Tests/images/truncated_jpeg.jpg" ImageFile.LOAD_TRUNCATED_IMAGES = True with Image.open(filename) as im: @@ -498,7 +524,7 @@ class TestFileJpeg: ImageFile.LOAD_TRUNCATED_IMAGES = False assert im.getbbox() is not None - def test_truncated_jpeg_throws_oserror(self): + def test_truncated_jpeg_throws_oserror(self) -> None: filename = "Tests/images/truncated_jpeg.jpg" with Image.open(filename) as im: with pytest.raises(OSError): @@ -511,8 +537,8 @@ class TestFileJpeg: @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_qtables(self, tmp_path): - def _n_qtables_helper(n, test_file): + def test_qtables(self, tmp_path: Path) -> None: + def _n_qtables_helper(n: int, test_file: str) -> None: with Image.open(test_file) as im: f = str(tmp_path / "temp.jpg") im.save(f, qtables=[[n] * 64] * n) @@ -620,24 +646,24 @@ class TestFileJpeg: with pytest.raises(ValueError): self.roundtrip(im, qtables=[[1, 2, 3, 4]]) - def test_load_16bit_qtables(self): + def test_load_16bit_qtables(self) -> None: with Image.open("Tests/images/hopper_16bit_qtables.jpg") as im: assert len(im.quantization) == 2 assert len(im.quantization[0]) == 64 assert max(im.quantization[0]) > 255 - def test_save_multiple_16bit_qtables(self): + def test_save_multiple_16bit_qtables(self) -> None: with Image.open("Tests/images/hopper_16bit_qtables.jpg") as im: im2 = self.roundtrip(im, qtables="keep") assert im.quantization == im2.quantization - def test_save_single_16bit_qtable(self): + def test_save_single_16bit_qtable(self) -> None: with Image.open("Tests/images/hopper_16bit_qtables.jpg") as im: im2 = self.roundtrip(im, qtables={0: im.quantization[0]}) assert len(im2.quantization) == 1 assert im2.quantization[0] == im.quantization[0] - def test_save_low_quality_baseline_qtables(self): + def test_save_low_quality_baseline_qtables(self) -> None: with Image.open(TEST_FILE) as im: im2 = self.roundtrip(im, quality=10) assert len(im2.quantization) == 2 @@ -648,7 +674,7 @@ class TestFileJpeg: "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): + def test_restart_markers(self, blocks: int, rows: int, markers: int) -> None: im = Image.new("RGB", (32, 32)) # 16 MCUs out = BytesIO() im.save( @@ -662,20 +688,20 @@ class TestFileJpeg: 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): + def test_load_djpeg(self) -> None: with Image.open(TEST_FILE) as img: img.load_djpeg() assert_image_similar_tofile(img, TEST_FILE, 5) @pytest.mark.skipif(not cjpeg_available(), reason="cjpeg not available") - def test_save_cjpeg(self, tmp_path): + def test_save_cjpeg(self, tmp_path: Path) -> None: with Image.open(TEST_FILE) as img: tempfile = str(tmp_path / "temp.jpg") JpegImagePlugin._save_cjpeg(img, 0, tempfile) # Default save quality is 75%, so a tiny bit of difference is alright assert_image_similar_tofile(img, tempfile, 17) - def test_no_duplicate_0x1001_tag(self): + def test_no_duplicate_0x1001_tag(self) -> None: # Arrange tag_ids = {v: k for k, v in ExifTags.TAGS.items()} @@ -683,7 +709,7 @@ class TestFileJpeg: assert tag_ids["RelatedImageWidth"] == 0x1001 assert tag_ids["RelatedImageLength"] == 0x1002 - def test_MAXBLOCK_scaling(self, tmp_path): + def test_MAXBLOCK_scaling(self, tmp_path: Path) -> None: im = self.gen_random_image((512, 512)) f = str(tmp_path / "temp.jpeg") im.save(f, quality=100, optimize=True) @@ -694,7 +720,7 @@ class TestFileJpeg: reloaded.save(f, quality="keep", progressive=True) reloaded.save(f, quality="keep", optimize=True) - def test_bad_mpo_header(self): + def test_bad_mpo_header(self) -> None: """Treat unknown MPO as JPEG""" # Arrange @@ -706,20 +732,20 @@ class TestFileJpeg: assert im.format == "JPEG" @pytest.mark.parametrize("mode", ("1", "L", "RGB", "RGBX", "CMYK", "YCbCr")) - def test_save_correct_modes(self, mode): + def test_save_correct_modes(self, mode: str) -> None: out = BytesIO() img = Image.new(mode, (20, 20)) img.save(out, "JPEG") @pytest.mark.parametrize("mode", ("LA", "La", "RGBA", "RGBa", "P")) - def test_save_wrong_modes(self, mode): + def test_save_wrong_modes(self, mode: str) -> None: # ref https://github.com/python-pillow/Pillow/issues/2005 out = BytesIO() img = Image.new(mode, (20, 20)) with pytest.raises(OSError): img.save(out, "JPEG") - def test_save_tiff_with_dpi(self, tmp_path): + def test_save_tiff_with_dpi(self, tmp_path: Path) -> None: # Arrange outfile = str(tmp_path / "temp.tif") with Image.open("Tests/images/hopper.tif") as im: @@ -731,7 +757,7 @@ class TestFileJpeg: reloaded.load() assert im.info["dpi"] == reloaded.info["dpi"] - def test_save_dpi_rounding(self, tmp_path): + def test_save_dpi_rounding(self, tmp_path: Path) -> None: outfile = str(tmp_path / "temp.jpg") with Image.open("Tests/images/hopper.jpg") as im: im.save(outfile, dpi=(72.2, 72.2)) @@ -744,7 +770,7 @@ class TestFileJpeg: with Image.open(outfile) as reloaded: assert reloaded.info["dpi"] == (73, 73) - def test_dpi_tuple_from_exif(self): + def test_dpi_tuple_from_exif(self) -> None: # Arrange # This Photoshop CC 2017 image has DPI in EXIF not metadata # EXIF XResolution is (2000000, 10000) @@ -752,7 +778,7 @@ class TestFileJpeg: # Act / Assert assert im.info.get("dpi") == (200, 200) - def test_dpi_int_from_exif(self): + def test_dpi_int_from_exif(self) -> None: # Arrange # This image has DPI in EXIF not metadata # EXIF XResolution is 72 @@ -760,7 +786,7 @@ class TestFileJpeg: # Act / Assert assert im.info.get("dpi") == (72, 72) - def test_dpi_from_dpcm_exif(self): + def test_dpi_from_dpcm_exif(self) -> None: # Arrange # This is photoshop-200dpi.jpg with EXIF resolution unit set to cm: # exiftool -exif:ResolutionUnit=cm photoshop-200dpi.jpg @@ -768,7 +794,7 @@ class TestFileJpeg: # Act / Assert assert im.info.get("dpi") == (508, 508) - def test_dpi_exif_zero_division(self): + def test_dpi_exif_zero_division(self) -> None: # Arrange # This is photoshop-200dpi.jpg with EXIF resolution set to 0/0: # exiftool -XResolution=0/0 -YResolution=0/0 photoshop-200dpi.jpg @@ -777,7 +803,7 @@ class TestFileJpeg: # This should return the default, and not raise a ZeroDivisionError assert im.info.get("dpi") == (72, 72) - def test_dpi_exif_string(self): + def test_dpi_exif_string(self) -> None: # Arrange # 0x011A tag in this exif contains string '300300\x02' with Image.open("Tests/images/broken_exif_dpi.jpg") as im: @@ -785,14 +811,14 @@ class TestFileJpeg: # This should return the default assert im.info.get("dpi") == (72, 72) - def test_dpi_exif_truncated(self): + def test_dpi_exif_truncated(self) -> None: # 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) - def test_no_dpi_in_exif(self): + def test_no_dpi_in_exif(self) -> None: # Arrange # This is photoshop-200dpi.jpg with resolution removed from EXIF: # exiftool "-*resolution*"= photoshop-200dpi.jpg @@ -802,7 +828,7 @@ class TestFileJpeg: # https://exiv2.org/tags.html assert im.info.get("dpi") == (72, 72) - def test_invalid_exif(self): + def test_invalid_exif(self) -> None: # 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: @@ -813,7 +839,7 @@ class TestFileJpeg: @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_exif_x_resolution(self, tmp_path): + def test_exif_x_resolution(self, tmp_path: Path) -> None: with Image.open("Tests/images/flower.jpg") as im: exif = im.getexif() assert exif[282] == 180 @@ -825,14 +851,14 @@ class TestFileJpeg: with Image.open(out) as reloaded: assert reloaded.getexif()[282] == 180 - def test_invalid_exif_x_resolution(self): + def test_invalid_exif_x_resolution(self) -> None: # 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) - def test_ifd_offset_exif(self): + def test_ifd_offset_exif(self) -> None: # Arrange # This image has been manually hexedited to have an IFD offset of 10, # in contrast to normal 8 @@ -840,10 +866,14 @@ class TestFileJpeg: # Act / Assert assert im._getexif()[306] == "2017:03:13 23:03:09" + def test_multiple_exif(self) -> None: + with Image.open("Tests/images/multiple_exif.jpg") as im: + assert im.info["exif"] == b"Exif\x00\x00firstsecond" + @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_photoshop(self): + def test_photoshop(self) -> None: with Image.open("Tests/images/photoshop-200dpi.jpg") as im: assert im.info["photoshop"][0x03ED] == { "XResolution": 200.0, @@ -860,14 +890,14 @@ class TestFileJpeg: with Image.open("Tests/images/app13.jpg") as im: assert "photoshop" not in im.info - def test_photoshop_malformed_and_multiple(self): + def test_photoshop_malformed_and_multiple(self) -> None: with Image.open("Tests/images/app13-multiple.jpg") as im: assert "photoshop" in im.info assert 24 == len(im.info["photoshop"]) apps_13_lengths = [len(v) for k, v in im.applist if k == "APP13"] assert [65504, 24] == apps_13_lengths - def test_adobe_transform(self): + def test_adobe_transform(self) -> None: with Image.open("Tests/images/pil_sample_rgb.jpg") as im: assert im.info["adobe_transform"] == 1 @@ -881,11 +911,11 @@ class TestFileJpeg: assert "adobe" in im.info assert "adobe_transform" not in im.info - def test_icc_after_SOF(self): + def test_icc_after_SOF(self) -> None: with Image.open("Tests/images/icc-after-SOF.jpg") as im: assert im.info["icc_profile"] == b"profile" - def test_jpeg_magic_number(self): + def test_jpeg_magic_number(self) -> None: size = 4097 buffer = BytesIO(b"\xFF" * size) # Many xFF bytes buffer.max_pos = 0 @@ -904,7 +934,7 @@ class TestFileJpeg: # Assert the entire file has not been read assert 0 < buffer.max_pos < size - def test_getxmp(self): + def test_getxmp(self) -> None: with Image.open("Tests/images/xmp_test.jpg") as im: if ElementTree is None: with pytest.warns( @@ -933,7 +963,7 @@ class TestFileJpeg: with Image.open("Tests/images/hopper.jpg") as im: assert im.getxmp() == {} - def test_getxmp_no_prefix(self): + def test_getxmp_no_prefix(self) -> None: with Image.open("Tests/images/xmp_no_prefix.jpg") as im: if ElementTree is None: with pytest.warns( @@ -944,7 +974,7 @@ class TestFileJpeg: else: assert im.getxmp() == {"xmpmeta": {"key": "value"}} - def test_getxmp_padded(self): + def test_getxmp_padded(self) -> None: with Image.open("Tests/images/xmp_padded.jpg") as im: if ElementTree is None: with pytest.warns( @@ -956,20 +986,14 @@ class TestFileJpeg: assert im.getxmp() == {"xmpmeta": None} @pytest.mark.timeout(timeout=1) - def test_eof(self): + def test_eof(self) -> None: # Even though this decoder never says that it is finished # the image should still end when there is no new data class InfiniteMockPyDecoder(ImageFile.PyDecoder): - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: return 0, 0 - decoder = InfiniteMockPyDecoder(None) - - def closure(mode, *args): - decoder.__init__(mode, *args) - return decoder - - Image.register_decoder("INFINITE", closure) + Image.register_decoder("INFINITE", InfiniteMockPyDecoder) with Image.open(TEST_FILE) as im: im.tile = [ @@ -979,7 +1003,7 @@ class TestFileJpeg: im.load() ImageFile.LOAD_TRUNCATED_IMAGES = False - def test_separate_tables(self): + def test_separate_tables(self) -> None: im = hopper() data = [] # [interchange, tables-only, image-only] for streamtype in range(3): @@ -1001,14 +1025,14 @@ class TestFileJpeg: with Image.open(BytesIO(data[1] + data[2])) as combined_im: assert_image_equal(interchange_im, combined_im) - def test_repr_jpeg(self): + def test_repr_jpeg(self) -> None: 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): + def test_repr_jpeg_error_returns_none(self) -> None: im = hopper("F") assert im._repr_jpeg_() is None @@ -1017,7 +1041,7 @@ class TestFileJpeg: @pytest.mark.skipif(not is_win32(), reason="Windows only") @skip_unless_feature("jpg") class TestFileCloseW32: - def test_fd_leak(self, tmp_path): + def test_fd_leak(self, tmp_path: Path) -> None: tmpfile = str(tmp_path / "temp.jpg") with Image.open("Tests/images/hopper.jpg") as im: diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index aaa4104e5..b7f8350c7 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -1,7 +1,10 @@ from __future__ import annotations + import os import re from io import BytesIO +from pathlib import Path +from typing import Any import pytest @@ -34,18 +37,16 @@ test_card.load() # 'Not enough memory to handle tile data' -def roundtrip(im, **options): +def roundtrip(im: Image.Image, **options: Any) -> Image.Image: out = BytesIO() im.save(out, "JPEG2000", **options) - test_bytes = out.tell() out.seek(0) with Image.open(out) as im: - im.bytes = test_bytes # for testing only im.load() return im -def test_sanity(): +def test_sanity() -> None: # Internal version number assert re.search(r"\d+\.\d+\.\d+$", features.version_codec("jpg_2000")) @@ -58,30 +59,32 @@ def test_sanity(): assert im.get_format_mimetype() == "image/jp2" -def test_jpf(): +def test_jpf() -> None: with Image.open("Tests/images/balloon.jpf") as im: assert im.format == "JPEG2000" assert im.get_format_mimetype() == "image/jpx" -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): Jpeg2KImagePlugin.Jpeg2KImageFile(invalid_file) -def test_bytesio(): +def test_bytesio() -> None: with open("Tests/images/test-card-lossless.jp2", "rb") as f: data = BytesIO(f.read()) - assert_image_similar_tofile(test_card, data, 1.0e-3) + with Image.open(data) as im: + im.load() + assert_image_similar(im, test_card, 1.0e-3) # These two test pre-written JPEG 2000 files that were not written with # PIL (they were made using Adobe Photoshop) -def test_lossless(tmp_path): +def test_lossless(tmp_path: Path) -> None: with Image.open("Tests/images/test-card-lossless.jp2") as im: im.load() outfile = str(tmp_path / "temp_test-card.png") @@ -89,54 +92,54 @@ def test_lossless(tmp_path): assert_image_similar(im, test_card, 1.0e-3) -def test_lossy_tiled(): +def test_lossy_tiled() -> None: assert_image_similar_tofile( test_card, "Tests/images/test-card-lossy-tiled.jp2", 2.0 ) -def test_lossless_rt(): +def test_lossless_rt() -> None: im = roundtrip(test_card) assert_image_equal(im, test_card) -def test_lossy_rt(): +def test_lossy_rt() -> None: im = roundtrip(test_card, quality_layers=[20]) assert_image_similar(im, test_card, 2.0) -def test_tiled_rt(): +def test_tiled_rt() -> None: im = roundtrip(test_card, tile_size=(128, 128)) assert_image_equal(im, test_card) -def test_tiled_offset_rt(): +def test_tiled_offset_rt() -> None: im = roundtrip(test_card, tile_size=(128, 128), tile_offset=(0, 0), offset=(32, 32)) assert_image_equal(im, test_card) -def test_tiled_offset_too_small(): +def test_tiled_offset_too_small() -> None: with pytest.raises(ValueError): roundtrip(test_card, tile_size=(128, 128), tile_offset=(0, 0), offset=(128, 32)) -def test_irreversible_rt(): +def test_irreversible_rt() -> None: im = roundtrip(test_card, irreversible=True, quality_layers=[20]) assert_image_similar(im, test_card, 2.0) -def test_prog_qual_rt(): +def test_prog_qual_rt() -> None: im = roundtrip(test_card, quality_layers=[60, 40, 20], progression="LRCP") assert_image_similar(im, test_card, 2.0) -def test_prog_res_rt(): +def test_prog_res_rt() -> None: im = roundtrip(test_card, num_resolutions=8, progression="RLCP") assert_image_equal(im, test_card) @pytest.mark.parametrize("num_resolutions", range(2, 6)) -def test_default_num_resolutions(num_resolutions): +def test_default_num_resolutions(num_resolutions: int) -> None: d = 1 << (num_resolutions - 1) im = test_card.resize((d - 1, d - 1)) with pytest.raises(OSError): @@ -145,7 +148,7 @@ def test_default_num_resolutions(num_resolutions): assert_image_equal(im, reloaded) -def test_reduce(): +def test_reduce() -> None: with Image.open("Tests/images/test-card-lossless.jp2") as im: assert callable(im.reduce) @@ -159,7 +162,7 @@ def test_reduce(): assert im.size == (40, 30) -def test_load_dpi(): +def test_load_dpi() -> None: with Image.open("Tests/images/test-card-lossless.jp2") as im: assert im.info["dpi"] == (71.9836, 71.9836) @@ -167,7 +170,7 @@ def test_load_dpi(): assert "dpi" not in im.info -def test_restricted_icc_profile(): +def test_restricted_icc_profile() -> None: ImageFile.LOAD_TRUNCATED_IMAGES = True try: # JPEG2000 image with a restricted ICC profile and a known colorspace @@ -177,7 +180,7 @@ def test_restricted_icc_profile(): ImageFile.LOAD_TRUNCATED_IMAGES = False -def test_header_errors(): +def test_header_errors() -> None: for path in ( "Tests/images/invalid_header_length.jp2", "Tests/images/not_enough_data.jp2", @@ -191,17 +194,17 @@ def test_header_errors(): pass -def test_layers_type(tmp_path): +def test_layers_type(tmp_path: Path) -> None: outfile = str(tmp_path / "temp_layers.jp2") for quality_layers in [[100, 50, 10], (100, 50, 10), None]: test_card.save(outfile, quality_layers=quality_layers) - for quality_layers in ["quality_layers", ("100", "50", "10")]: + for quality_layers_str in ["quality_layers", ("100", "50", "10")]: with pytest.raises(ValueError): - test_card.save(outfile, quality_layers=quality_layers) + test_card.save(outfile, quality_layers=quality_layers_str) -def test_layers(): +def test_layers() -> None: out = BytesIO() test_card.save(out, "JPEG2000", quality_layers=[100, 50, 10], progression="LRCP") out.seek(0) @@ -231,7 +234,7 @@ def test_layers(): ("foo.jp2", {"no_jp2": False}, 4, b"jP"), ), ) -def test_no_jp2(name, args, offset, data): +def test_no_jp2(name: str, args: dict[str, bool], offset: int, data: bytes) -> None: out = BytesIO() if name: out.name = name @@ -240,7 +243,7 @@ def test_no_jp2(name, args, offset, data): assert out.read(2) == data -def test_mct(): +def test_mct() -> None: # Three component for val in (0, 1): out = BytesIO() @@ -261,7 +264,7 @@ def test_mct(): assert_image_similar(im, jp2, 1.0e-3) -def test_sgnd(tmp_path): +def test_sgnd(tmp_path: Path) -> None: outfile = str(tmp_path / "temp.jp2") im = Image.new("L", (1, 1)) @@ -276,7 +279,7 @@ def test_sgnd(tmp_path): @pytest.mark.parametrize("ext", (".j2k", ".jp2")) -def test_rgba(ext): +def test_rgba(ext: str) -> None: # Arrange with Image.open("Tests/images/rgb_trns_ycbc" + ext) as im: # Act @@ -287,47 +290,47 @@ def test_rgba(ext): @pytest.mark.parametrize("ext", (".j2k", ".jp2")) -def test_16bit_monochrome_has_correct_mode(ext): +def test_16bit_monochrome_has_correct_mode(ext: str) -> None: with Image.open("Tests/images/16bit.cropped" + ext) as im: im.load() assert im.mode == "I;16" -def test_16bit_monochrome_jp2_like_tiff(): +def test_16bit_monochrome_jp2_like_tiff() -> None: with Image.open("Tests/images/16bit.cropped.tif") as tiff_16bit: assert_image_similar_tofile(tiff_16bit, "Tests/images/16bit.cropped.jp2", 1e-3) -def test_16bit_monochrome_j2k_like_tiff(): +def test_16bit_monochrome_j2k_like_tiff() -> None: with Image.open("Tests/images/16bit.cropped.tif") as tiff_16bit: assert_image_similar_tofile(tiff_16bit, "Tests/images/16bit.cropped.j2k", 1e-3) -def test_16bit_j2k_roundtrips(): +def test_16bit_j2k_roundtrips() -> None: with Image.open("Tests/images/16bit.cropped.j2k") as j2k: im = roundtrip(j2k) assert_image_equal(im, j2k) -def test_16bit_jp2_roundtrips(): +def test_16bit_jp2_roundtrips() -> None: with Image.open("Tests/images/16bit.cropped.jp2") as jp2: im = roundtrip(jp2) assert_image_equal(im, jp2) -def test_issue_6194(): +def test_issue_6194() -> None: with Image.open("Tests/images/issue_6194.j2k") as im: assert im.getpixel((5, 5)) == 31 -def test_unbound_local(): +def test_unbound_local() -> None: # prepatch, a malformed jp2 file could cause an UnboundLocalError exception. with pytest.raises(OSError): with Image.open("Tests/images/unbound_variable.jp2"): pass -def test_parser_feed(): +def test_parser_feed() -> None: # Arrange with open("Tests/images/test-card-lossless.jp2", "rb") as f: data = f.read() @@ -337,6 +340,7 @@ def test_parser_feed(): p.feed(data) # Assert + assert p.image is not None assert p.image.size == (640, 480) @@ -344,12 +348,12 @@ def test_parser_feed(): not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" ) @pytest.mark.parametrize("name", ("subsampling_1", "subsampling_2", "zoo1", "zoo2")) -def test_subsampling_decode(name): +def test_subsampling_decode(name: str) -> None: test = f"{EXTRA_DIR}/{name}.jp2" reference = f"{EXTRA_DIR}/{name}.ppm" with Image.open(test) as im: - epsilon = 3 # for YCbCr images + epsilon = 3.0 # for YCbCr images with Image.open(reference) as im2: width, height = im2.size if name[-1] == "2": @@ -360,7 +364,7 @@ def test_subsampling_decode(name): assert_image_similar(im, expected, epsilon) -def test_comment(): +def test_comment() -> None: with Image.open("Tests/images/comment.jp2") as im: assert im.info["comment"] == b"Created by OpenJPEG version 2.5.0" @@ -371,7 +375,7 @@ def test_comment(): pass -def test_save_comment(): +def test_save_comment() -> None: for comment in ("Created by Pillow", b"Created by Pillow"): out = BytesIO() test_card.save(out, "JPEG2000", comment=comment) @@ -398,7 +402,7 @@ def test_save_comment(): "Tests/images/crash-d2c93af851d3ab9a19e34503626368b2ecde9c03.j2k", ], ) -def test_crashes(test_file): +def test_crashes(test_file: str) -> None: with open(test_file, "rb") as f: with Image.open(f) as im: # Valgrind should not complain here @@ -409,7 +413,7 @@ def test_crashes(test_file): @skip_unless_feature_version("jpg_2000", "2.4.0") -def test_plt_marker(): +def test_plt_marker() -> None: # Search the start of the codesteam for PLT out = BytesIO() test_card.save(out, "JPEG2000", no_jp2=True, plt=True) diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index 65adf449d..908464a11 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -1,4 +1,5 @@ from __future__ import annotations + import base64 import io import itertools @@ -6,6 +7,7 @@ import os import re import sys from collections import namedtuple +from pathlib import Path import pytest @@ -25,7 +27,7 @@ from .helper import ( @skip_unless_feature("libtiff") class LibTiffTestCase: - def _assert_noerr(self, tmp_path, im): + def _assert_noerr(self, tmp_path: Path, im: TiffImagePlugin.TiffImageFile) -> None: """Helper tests that assert basic sanity about the g4 tiff reading""" # 1 bit assert im.mode == "1" @@ -49,10 +51,10 @@ class LibTiffTestCase: class TestFileLibTiff(LibTiffTestCase): - def test_version(self): + def test_version(self) -> None: assert re.search(r"\d+\.\d+\.\d+$", features.version_codec("libtiff")) - def test_g4_tiff(self, tmp_path): + def test_g4_tiff(self, tmp_path: Path) -> None: """Test the ordinary file path load path""" test_file = "Tests/images/hopper_g4_500.tif" @@ -60,12 +62,12 @@ class TestFileLibTiff(LibTiffTestCase): assert im.size == (500, 500) self._assert_noerr(tmp_path, im) - def test_g4_large(self, tmp_path): + def test_g4_large(self, tmp_path: Path) -> None: test_file = "Tests/images/pport_g4.tif" with Image.open(test_file) as im: self._assert_noerr(tmp_path, im) - def test_g4_tiff_file(self, tmp_path): + def test_g4_tiff_file(self, tmp_path: Path) -> None: """Testing the string load path""" test_file = "Tests/images/hopper_g4_500.tif" @@ -74,7 +76,7 @@ class TestFileLibTiff(LibTiffTestCase): assert im.size == (500, 500) self._assert_noerr(tmp_path, im) - def test_g4_tiff_bytesio(self, tmp_path): + def test_g4_tiff_bytesio(self, tmp_path: Path) -> None: """Testing the stringio loading code path""" test_file = "Tests/images/hopper_g4_500.tif" s = io.BytesIO() @@ -85,7 +87,7 @@ class TestFileLibTiff(LibTiffTestCase): assert im.size == (500, 500) self._assert_noerr(tmp_path, im) - def test_g4_non_disk_file_object(self, tmp_path): + def test_g4_non_disk_file_object(self, tmp_path: Path) -> None: """Testing loading from non-disk non-BytesIO file object""" test_file = "Tests/images/hopper_g4_500.tif" s = io.BytesIO() @@ -97,18 +99,18 @@ class TestFileLibTiff(LibTiffTestCase): assert im.size == (500, 500) self._assert_noerr(tmp_path, im) - def test_g4_eq_png(self): + def test_g4_eq_png(self) -> None: """Checking that we're actually getting the data that we expect""" with Image.open("Tests/images/hopper_bw_500.png") as png: assert_image_equal_tofile(png, "Tests/images/hopper_g4_500.tif") # see https://github.com/python-pillow/Pillow/issues/279 - def test_g4_fillorder_eq_png(self): + def test_g4_fillorder_eq_png(self) -> None: """Checking that we're actually getting the data that we expect""" with Image.open("Tests/images/g4-fillorder-test.tif") as g4: assert_image_equal_tofile(g4, "Tests/images/g4-fillorder-test.png") - def test_g4_write(self, tmp_path): + def test_g4_write(self, tmp_path: Path) -> None: """Checking to see that the saved image is the same as what we wrote""" test_file = "Tests/images/hopper_g4_500.tif" with Image.open(test_file) as orig: @@ -127,7 +129,7 @@ class TestFileLibTiff(LibTiffTestCase): assert orig.tobytes() != reread.tobytes() - def test_adobe_deflate_tiff(self): + def test_adobe_deflate_tiff(self) -> None: test_file = "Tests/images/tiff_adobe_deflate.tif" with Image.open(test_file) as im: assert im.mode == "RGB" @@ -138,7 +140,7 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_equal_tofile(im, "Tests/images/tiff_adobe_deflate.png") @pytest.mark.parametrize("legacy_api", (False, True)) - def test_write_metadata(self, legacy_api, tmp_path): + def test_write_metadata(self, legacy_api: bool, tmp_path: Path) -> None: """Test metadata writing through libtiff""" f = str(tmp_path / "temp.tiff") with Image.open("Tests/images/hopper_g4.tif") as img: @@ -183,7 +185,7 @@ class TestFileLibTiff(LibTiffTestCase): assert field in reloaded, f"{field} not in metadata" @pytest.mark.valgrind_known_error(reason="Known invalid metadata") - def test_additional_metadata(self, tmp_path): + def test_additional_metadata(self, tmp_path: Path) -> None: # these should not crash. Seriously dummy data, most of it doesn't make # any sense, so we're running up against limits where we're asking # libtiff to do stupid things. @@ -240,8 +242,8 @@ class TestFileLibTiff(LibTiffTestCase): TiffImagePlugin.WRITE_LIBTIFF = False - def test_custom_metadata(self, tmp_path): - tc = namedtuple("test_case", "value,type,supported_by_default") + def test_custom_metadata(self, tmp_path: Path) -> None: + tc = namedtuple("tc", "value,type,supported_by_default") custom = { 37000 + k: v for k, v in enumerate( @@ -282,7 +284,9 @@ class TestFileLibTiff(LibTiffTestCase): for libtiff in libtiffs: TiffImagePlugin.WRITE_LIBTIFF = libtiff - def check_tags(tiffinfo): + def check_tags( + tiffinfo: TiffImagePlugin.ImageFileDirectory_v2 | dict[int, str] + ) -> None: im = hopper() out = str(tmp_path / "temp.tif") @@ -321,7 +325,7 @@ class TestFileLibTiff(LibTiffTestCase): ) TiffImagePlugin.WRITE_LIBTIFF = False - def test_subifd(self, tmp_path): + def test_subifd(self, tmp_path: Path) -> None: outfile = str(tmp_path / "temp.tif") with Image.open("Tests/images/g4_orientation_6.tif") as im: im.tag_v2[SUBIFD] = 10000 @@ -329,7 +333,7 @@ class TestFileLibTiff(LibTiffTestCase): # Should not segfault im.save(outfile) - def test_xmlpacket_tag(self, tmp_path): + def test_xmlpacket_tag(self, tmp_path: Path) -> None: TiffImagePlugin.WRITE_LIBTIFF = True out = str(tmp_path / "temp.tif") @@ -340,7 +344,7 @@ class TestFileLibTiff(LibTiffTestCase): if 700 in reloaded.tag_v2: assert reloaded.tag_v2[700] == b"xmlpacket tag" - def test_int_dpi(self, tmp_path): + def test_int_dpi(self, tmp_path: Path) -> None: # issue #1765 im = hopper("RGB") out = str(tmp_path / "temp.tif") @@ -350,7 +354,7 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open(out) as reloaded: assert reloaded.info["dpi"] == (72.0, 72.0) - def test_g3_compression(self, tmp_path): + def test_g3_compression(self, tmp_path: Path) -> None: with Image.open("Tests/images/hopper_g4_500.tif") as i: out = str(tmp_path / "temp.tif") i.save(out, compression="group3") @@ -359,7 +363,7 @@ class TestFileLibTiff(LibTiffTestCase): assert reread.info["compression"] == "group3" assert_image_equal(reread, i) - def test_little_endian(self, tmp_path): + def test_little_endian(self, tmp_path: Path) -> None: with Image.open("Tests/images/16bit.deflate.tif") as im: assert im.getpixel((0, 0)) == 480 assert im.mode == "I;16" @@ -378,7 +382,7 @@ class TestFileLibTiff(LibTiffTestCase): # UNDONE - libtiff defaults to writing in native endian, so # on big endian, we'll get back mode = 'I;16B' here. - def test_big_endian(self, tmp_path): + def test_big_endian(self, tmp_path: Path) -> None: with Image.open("Tests/images/16bit.MM.deflate.tif") as im: assert im.getpixel((0, 0)) == 480 assert im.mode == "I;16B" @@ -395,7 +399,7 @@ class TestFileLibTiff(LibTiffTestCase): assert reread.info["compression"] == im.info["compression"] assert reread.getpixel((0, 0)) == 480 - def test_g4_string_info(self, tmp_path): + def test_g4_string_info(self, tmp_path: Path) -> None: """Tests String data in info directory""" test_file = "Tests/images/hopper_g4_500.tif" with Image.open(test_file) as orig: @@ -408,7 +412,7 @@ class TestFileLibTiff(LibTiffTestCase): assert "temp.tif" == reread.tag_v2[269] assert "temp.tif" == reread.tag[269][0] - def test_12bit_rawmode(self): + def test_12bit_rawmode(self) -> None: """Are we generating the same interpretation of the image as Imagemagick is?""" TiffImagePlugin.READ_LIBTIFF = True @@ -423,7 +427,7 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_equal_tofile(im, "Tests/images/12in16bit.tif") - def test_blur(self, tmp_path): + def test_blur(self, tmp_path: Path) -> None: # test case from irc, how to do blur on b/w image # and save to compressed tif. out = str(tmp_path / "temp.tif") @@ -435,7 +439,7 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_equal_tofile(im, out) - def test_compressions(self, tmp_path): + def test_compressions(self, tmp_path: Path) -> None: # Test various tiff compressions and assert similar image content but reduced # file sizes. im = hopper("RGB") @@ -461,7 +465,7 @@ class TestFileLibTiff(LibTiffTestCase): assert size_compressed > size_jpeg assert size_jpeg > size_jpeg_30 - def test_tiff_jpeg_compression(self, tmp_path): + def test_tiff_jpeg_compression(self, tmp_path: Path) -> None: im = hopper("RGB") out = str(tmp_path / "temp.tif") im.save(out, compression="tiff_jpeg") @@ -469,7 +473,7 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open(out) as reloaded: assert reloaded.info["compression"] == "jpeg" - def test_tiff_deflate_compression(self, tmp_path): + def test_tiff_deflate_compression(self, tmp_path: Path) -> None: im = hopper("RGB") out = str(tmp_path / "temp.tif") im.save(out, compression="tiff_deflate") @@ -477,7 +481,7 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open(out) as reloaded: assert reloaded.info["compression"] == "tiff_adobe_deflate" - def test_quality(self, tmp_path): + def test_quality(self, tmp_path: Path) -> None: im = hopper("RGB") out = str(tmp_path / "temp.tif") @@ -492,7 +496,7 @@ class TestFileLibTiff(LibTiffTestCase): im.save(out, compression="jpeg", quality=0) im.save(out, compression="jpeg", quality=100) - def test_cmyk_save(self, tmp_path): + def test_cmyk_save(self, tmp_path: Path) -> None: im = hopper("CMYK") out = str(tmp_path / "temp.tif") @@ -500,7 +504,7 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_equal_tofile(im, out) @pytest.mark.parametrize("im", (hopper("P"), Image.new("P", (1, 1), "#000"))) - def test_palette_save(self, im, tmp_path): + def test_palette_save(self, im: Image.Image, tmp_path: Path) -> None: out = str(tmp_path / "temp.tif") TiffImagePlugin.WRITE_LIBTIFF = True @@ -512,15 +516,16 @@ class TestFileLibTiff(LibTiffTestCase): assert len(reloaded.tag_v2[320]) == 768 @pytest.mark.parametrize("compression", ("tiff_ccitt", "group3", "group4")) - def test_bw_compression_w_rgb(self, compression, tmp_path): + def test_bw_compression_w_rgb(self, compression: str, tmp_path: Path) -> None: im = hopper("RGB") out = str(tmp_path / "temp.tif") with pytest.raises(OSError): im.save(out, compression=compression) - def test_fp_leak(self): - im = Image.open("Tests/images/hopper_g4_500.tif") + def test_fp_leak(self) -> None: + im: Image.Image | None = Image.open("Tests/images/hopper_g4_500.tif") + assert im is not None fn = im.fp.fileno() os.fstat(fn) @@ -533,7 +538,7 @@ class TestFileLibTiff(LibTiffTestCase): with pytest.raises(OSError): os.close(fn) - def test_multipage(self): + def test_multipage(self) -> None: # issue #862 TiffImagePlugin.READ_LIBTIFF = True with Image.open("Tests/images/multipage.tiff") as im: @@ -556,7 +561,7 @@ class TestFileLibTiff(LibTiffTestCase): TiffImagePlugin.READ_LIBTIFF = False - def test_multipage_nframes(self): + def test_multipage_nframes(self) -> None: # issue #862 TiffImagePlugin.READ_LIBTIFF = True with Image.open("Tests/images/multipage.tiff") as im: @@ -569,7 +574,7 @@ class TestFileLibTiff(LibTiffTestCase): TiffImagePlugin.READ_LIBTIFF = False - def test_multipage_seek_backwards(self): + def test_multipage_seek_backwards(self) -> None: TiffImagePlugin.READ_LIBTIFF = True with Image.open("Tests/images/multipage.tiff") as im: im.seek(1) @@ -580,14 +585,14 @@ class TestFileLibTiff(LibTiffTestCase): TiffImagePlugin.READ_LIBTIFF = False - def test__next(self): + def test__next(self) -> None: TiffImagePlugin.READ_LIBTIFF = True with Image.open("Tests/images/hopper.tif") as im: assert not im.tag.next im.load() assert not im.tag.next - def test_4bit(self): + def test_4bit(self) -> None: # Arrange test_file = "Tests/images/hopper_gray_4bpp.tif" original = hopper("L") @@ -602,7 +607,7 @@ class TestFileLibTiff(LibTiffTestCase): assert im.mode == "L" assert_image_similar(im, original, 7.3) - def test_gray_semibyte_per_pixel(self): + def test_gray_semibyte_per_pixel(self) -> None: test_files = ( ( 24.8, # epsilon @@ -635,7 +640,7 @@ class TestFileLibTiff(LibTiffTestCase): assert im2.mode == "L" assert_image_equal(im, im2) - def test_save_bytesio(self): + def test_save_bytesio(self) -> None: # PR 1011 # Test TIFF saving to io.BytesIO() object. @@ -645,7 +650,7 @@ class TestFileLibTiff(LibTiffTestCase): # Generate test image pilim = hopper() - def save_bytesio(compression=None): + def save_bytesio(compression: str | None = None) -> None: buffer_io = io.BytesIO() pilim.save(buffer_io, format="tiff", compression=compression) buffer_io.seek(0) @@ -660,7 +665,7 @@ class TestFileLibTiff(LibTiffTestCase): TiffImagePlugin.WRITE_LIBTIFF = False TiffImagePlugin.READ_LIBTIFF = False - def test_save_ycbcr(self, tmp_path): + def test_save_ycbcr(self, tmp_path: Path) -> None: im = hopper("YCbCr") outfile = str(tmp_path / "temp.tif") im.save(outfile, compression="jpeg") @@ -669,7 +674,7 @@ 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): + def test_exif_ifd(self, tmp_path: Path) -> None: outfile = str(tmp_path / "temp.tif") with Image.open("Tests/images/tiff_adobe_deflate.tif") as im: assert im.tag_v2[34665] == 125456 @@ -679,7 +684,7 @@ class TestFileLibTiff(LibTiffTestCase): if Image.core.libtiff_support_custom_tags: assert reloaded.tag_v2[34665] == 125456 - def test_crashing_metadata(self, tmp_path): + def test_crashing_metadata(self, tmp_path: Path) -> None: # issue 1597 with Image.open("Tests/images/rdf.tif") as im: out = str(tmp_path / "temp.tif") @@ -689,7 +694,7 @@ class TestFileLibTiff(LibTiffTestCase): im.save(out, format="TIFF") TiffImagePlugin.WRITE_LIBTIFF = False - def test_page_number_x_0(self, tmp_path): + def test_page_number_x_0(self, tmp_path: Path) -> None: # Issue 973 # Test TIFF with tag 297 (Page Number) having value of 0 0. # The first number is the current page number. @@ -703,7 +708,7 @@ class TestFileLibTiff(LibTiffTestCase): # Should not divide by zero im.save(outfile) - def test_fd_duplication(self, tmp_path): + def test_fd_duplication(self, tmp_path: Path) -> None: # https://github.com/python-pillow/Pillow/issues/1651 tmpfile = str(tmp_path / "temp.tif") @@ -712,12 +717,13 @@ class TestFileLibTiff(LibTiffTestCase): f.write(src.read()) im = Image.open(tmpfile) + assert isinstance(im, TiffImagePlugin.TiffImageFile) im.n_frames im.close() # Should not raise PermissionError. os.remove(tmpfile) - def test_read_icc(self): + def test_read_icc(self) -> None: with Image.open("Tests/images/hopper.iccprofile.tif") as img: icc = img.info.get("icc_profile") assert icc is not None @@ -728,8 +734,8 @@ class TestFileLibTiff(LibTiffTestCase): TiffImagePlugin.READ_LIBTIFF = False assert icc == icc_libtiff - def test_write_icc(self, tmp_path): - def check_write(libtiff): + def test_write_icc(self, tmp_path: Path) -> None: + def check_write(libtiff: bool) -> None: TiffImagePlugin.WRITE_LIBTIFF = libtiff with Image.open("Tests/images/hopper.iccprofile.tif") as img: @@ -748,7 +754,7 @@ class TestFileLibTiff(LibTiffTestCase): for libtiff in libtiffs: check_write(libtiff) - def test_multipage_compression(self): + def test_multipage_compression(self) -> None: with Image.open("Tests/images/compression.tif") as im: im.seek(0) assert im._compression == "tiff_ccitt" @@ -764,7 +770,7 @@ class TestFileLibTiff(LibTiffTestCase): assert im.size == (10, 10) im.load() - def test_save_tiff_with_jpegtables(self, tmp_path): + def test_save_tiff_with_jpegtables(self, tmp_path: Path) -> None: # Arrange outfile = str(tmp_path / "temp.tif") @@ -776,7 +782,7 @@ class TestFileLibTiff(LibTiffTestCase): # Should not raise UnicodeDecodeError or anything else im.save(outfile) - def test_16bit_RGB_tiff(self): + def test_16bit_RGB_tiff(self) -> None: with Image.open("Tests/images/tiff_16bit_RGB.tiff") as im: assert im.mode == "RGB" assert im.size == (100, 40) @@ -792,7 +798,7 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_equal_tofile(im, "Tests/images/tiff_16bit_RGB_target.png") - def test_16bit_RGBa_tiff(self): + def test_16bit_RGBa_tiff(self) -> None: with Image.open("Tests/images/tiff_16bit_RGBa.tiff") as im: assert im.mode == "RGBA" assert im.size == (100, 40) @@ -804,7 +810,7 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_equal_tofile(im, "Tests/images/tiff_16bit_RGBa_target.png") @skip_unless_feature("jpg") - def test_gimp_tiff(self): + def test_gimp_tiff(self) -> None: # Read TIFF JPEG images from GIMP [@PIL168] filename = "Tests/images/pil168.tif" with Image.open(filename) as im: @@ -817,14 +823,14 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_equal_tofile(im, "Tests/images/pil168.png") - def test_sampleformat(self): + def test_sampleformat(self) -> None: # https://github.com/python-pillow/Pillow/issues/1466 with Image.open("Tests/images/copyleft.tiff") as im: assert im.mode == "RGB" assert_image_equal_tofile(im, "Tests/images/copyleft.png", mode="RGB") - def test_sampleformat_write(self, tmp_path): + def test_sampleformat_write(self, tmp_path: Path) -> None: im = Image.new("F", (1, 1)) out = str(tmp_path / "temp.tif") TiffImagePlugin.WRITE_LIBTIFF = True @@ -835,7 +841,7 @@ class TestFileLibTiff(LibTiffTestCase): assert reloaded.mode == "F" assert reloaded.getexif()[SAMPLEFORMAT] == 3 - def test_lzma(self, capfd): + def test_lzma(self, capfd: pytest.CaptureFixture[str]) -> None: try: with Image.open("Tests/images/hopper_lzma.tif") as im: assert im.mode == "RGB" @@ -851,7 +857,7 @@ class TestFileLibTiff(LibTiffTestCase): sys.stderr.write(captured.err) raise - def test_webp(self, capfd): + def test_webp(self, capfd: pytest.CaptureFixture[str]) -> None: try: with Image.open("Tests/images/hopper_webp.tif") as im: assert im.mode == "RGB" @@ -873,7 +879,7 @@ class TestFileLibTiff(LibTiffTestCase): sys.stderr.write(captured.err) raise - def test_lzw(self): + def test_lzw(self) -> None: with Image.open("Tests/images/hopper_lzw.tif") as im: assert im.mode == "RGB" assert im.size == (128, 128) @@ -881,12 +887,12 @@ class TestFileLibTiff(LibTiffTestCase): im2 = hopper() assert_image_similar(im, im2, 5) - def test_strip_cmyk_jpeg(self): + def test_strip_cmyk_jpeg(self) -> None: infile = "Tests/images/tiff_strip_cmyk_jpeg.tif" with Image.open(infile) as im: assert_image_similar_tofile(im, "Tests/images/pil_sample_cmyk.jpg", 0.5) - def test_strip_cmyk_16l_jpeg(self): + def test_strip_cmyk_16l_jpeg(self) -> None: infile = "Tests/images/tiff_strip_cmyk_16l_jpeg.tif" with Image.open(infile) as im: assert_image_similar_tofile(im, "Tests/images/pil_sample_cmyk.jpg", 0.5) @@ -894,7 +900,7 @@ class TestFileLibTiff(LibTiffTestCase): @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_strip_ycbcr_jpeg_2x2_sampling(self): + def test_strip_ycbcr_jpeg_2x2_sampling(self) -> None: infile = "Tests/images/tiff_strip_ycbcr_jpeg_2x2_sampling.tif" with Image.open(infile) as im: assert_image_similar_tofile(im, "Tests/images/flower.jpg", 1.2) @@ -902,12 +908,12 @@ class TestFileLibTiff(LibTiffTestCase): @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_strip_ycbcr_jpeg_1x1_sampling(self): + def test_strip_ycbcr_jpeg_1x1_sampling(self) -> None: infile = "Tests/images/tiff_strip_ycbcr_jpeg_1x1_sampling.tif" with Image.open(infile) as im: assert_image_similar_tofile(im, "Tests/images/flower2.jpg", 0.01) - def test_tiled_cmyk_jpeg(self): + def test_tiled_cmyk_jpeg(self) -> None: infile = "Tests/images/tiff_tiled_cmyk_jpeg.tif" with Image.open(infile) as im: assert_image_similar_tofile(im, "Tests/images/pil_sample_cmyk.jpg", 0.5) @@ -915,7 +921,7 @@ class TestFileLibTiff(LibTiffTestCase): @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_tiled_ycbcr_jpeg_1x1_sampling(self): + def test_tiled_ycbcr_jpeg_1x1_sampling(self) -> None: infile = "Tests/images/tiff_tiled_ycbcr_jpeg_1x1_sampling.tif" with Image.open(infile) as im: assert_image_similar_tofile(im, "Tests/images/flower2.jpg", 0.01) @@ -923,45 +929,45 @@ class TestFileLibTiff(LibTiffTestCase): @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_tiled_ycbcr_jpeg_2x2_sampling(self): + def test_tiled_ycbcr_jpeg_2x2_sampling(self) -> None: infile = "Tests/images/tiff_tiled_ycbcr_jpeg_2x2_sampling.tif" with Image.open(infile) as im: assert_image_similar_tofile(im, "Tests/images/flower.jpg", 1.5) - def test_strip_planar_rgb(self): + def test_strip_planar_rgb(self) -> None: # gdal_translate -co TILED=no -co INTERLEAVE=BAND -co COMPRESS=LZW \ # tiff_strip_raw.tif tiff_strip_planar_lzw.tiff infile = "Tests/images/tiff_strip_planar_lzw.tiff" with Image.open(infile) as im: assert_image_equal_tofile(im, "Tests/images/tiff_adobe_deflate.png") - def test_tiled_planar_rgb(self): + def test_tiled_planar_rgb(self) -> None: # gdal_translate -co TILED=yes -co INTERLEAVE=BAND -co COMPRESS=LZW \ # tiff_tiled_raw.tif tiff_tiled_planar_lzw.tiff infile = "Tests/images/tiff_tiled_planar_lzw.tiff" with Image.open(infile) as im: assert_image_equal_tofile(im, "Tests/images/tiff_adobe_deflate.png") - def test_tiled_planar_16bit_RGB(self): + def test_tiled_planar_16bit_RGB(self) -> None: # gdal_translate -co TILED=yes -co INTERLEAVE=BAND -co COMPRESS=LZW \ # tiff_16bit_RGB.tiff tiff_tiled_planar_16bit_RGB.tiff with Image.open("Tests/images/tiff_tiled_planar_16bit_RGB.tiff") as im: assert_image_equal_tofile(im, "Tests/images/tiff_16bit_RGB_target.png") - def test_strip_planar_16bit_RGB(self): + def test_strip_planar_16bit_RGB(self) -> None: # gdal_translate -co TILED=no -co INTERLEAVE=BAND -co COMPRESS=LZW \ # tiff_16bit_RGB.tiff tiff_strip_planar_16bit_RGB.tiff with Image.open("Tests/images/tiff_strip_planar_16bit_RGB.tiff") as im: assert_image_equal_tofile(im, "Tests/images/tiff_16bit_RGB_target.png") - def test_tiled_planar_16bit_RGBa(self): + def test_tiled_planar_16bit_RGBa(self) -> None: # gdal_translate -co TILED=yes \ # -co INTERLEAVE=BAND -co COMPRESS=LZW -co ALPHA=PREMULTIPLIED \ # tiff_16bit_RGBa.tiff tiff_tiled_planar_16bit_RGBa.tiff with Image.open("Tests/images/tiff_tiled_planar_16bit_RGBa.tiff") as im: assert_image_equal_tofile(im, "Tests/images/tiff_16bit_RGBa_target.png") - def test_strip_planar_16bit_RGBa(self): + def test_strip_planar_16bit_RGBa(self) -> None: # gdal_translate -co TILED=no \ # -co INTERLEAVE=BAND -co COMPRESS=LZW -co ALPHA=PREMULTIPLIED \ # tiff_16bit_RGBa.tiff tiff_strip_planar_16bit_RGBa.tiff @@ -969,7 +975,7 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_equal_tofile(im, "Tests/images/tiff_16bit_RGBa_target.png") @pytest.mark.parametrize("compression", (None, "jpeg")) - def test_block_tile_tags(self, compression, tmp_path): + def test_block_tile_tags(self, compression: str | None, tmp_path: Path) -> None: im = hopper() out = str(tmp_path / "temp.tif") @@ -985,11 +991,11 @@ class TestFileLibTiff(LibTiffTestCase): for tag in tags: assert tag not in reloaded.getexif() - def test_old_style_jpeg(self): + def test_old_style_jpeg(self) -> None: with Image.open("Tests/images/old-style-jpeg-compression.tif") as im: assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png") - def test_open_missing_samplesperpixel(self): + def test_open_missing_samplesperpixel(self) -> None: with Image.open( "Tests/images/old-style-jpeg-compression-no-samplesperpixel.tif" ) as im: @@ -1018,21 +1024,23 @@ class TestFileLibTiff(LibTiffTestCase): ), ], ) - def test_wrong_bits_per_sample(self, file_name, mode, size, tile): + def test_wrong_bits_per_sample( + self, file_name: str, mode: str, size: tuple[int, int], tile + ) -> None: 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): + def test_no_rows_per_strip(self) -> None: # This image does not have a RowsPerStrip TIFF tag infile = "Tests/images/no_rows_per_strip.tif" with Image.open(infile) as im: im.load() assert im.size == (950, 975) - def test_orientation(self): + def test_orientation(self) -> None: 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: @@ -1043,7 +1051,7 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_similar(base_im, im, 0.7) - def test_exif_transpose(self): + def test_exif_transpose(self) -> None: 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: @@ -1052,7 +1060,7 @@ class TestFileLibTiff(LibTiffTestCase): assert_image_similar(base_im, im, 0.7) @pytest.mark.valgrind_known_error(reason="Backtrace in Python Core") - def test_sampleformat_not_corrupted(self): + def test_sampleformat_not_corrupted(self) -> None: # Assert that a TIFF image with SampleFormat=UINT tag is not corrupted # when saving to a new file. # Pillow 6.0 fails with "OSError: cannot identify image file". @@ -1073,7 +1081,7 @@ class TestFileLibTiff(LibTiffTestCase): with Image.open(out) as im: im.load() - def test_realloc_overflow(self): + def test_realloc_overflow(self) -> None: TiffImagePlugin.READ_LIBTIFF = True with Image.open("Tests/images/tiff_overflow_rows_per_strip.tif") as im: with pytest.raises(OSError) as e: @@ -1084,41 +1092,43 @@ class TestFileLibTiff(LibTiffTestCase): TiffImagePlugin.READ_LIBTIFF = False @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", "jpeg")) - def test_save_multistrip(self, compression, tmp_path): + def test_save_multistrip(self, compression: str, tmp_path: Path) -> None: im = hopper("RGB").resize((256, 256)) out = str(tmp_path / "temp.tif") im.save(out, compression=compression) with Image.open(out) as im: # Assert that there are multiple strips + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert len(im.tag_v2[STRIPOFFSETS]) > 1 @pytest.mark.parametrize("argument", (True, False)) - def test_save_single_strip(self, argument, tmp_path): + def test_save_single_strip(self, argument: bool, tmp_path: Path) -> None: im = hopper("RGB").resize((256, 256)) out = str(tmp_path / "temp.tif") if not argument: TiffImagePlugin.STRIP_SIZE = 2**18 try: - arguments = {"compression": "tiff_adobe_deflate"} + arguments: dict[str, str | int] = {"compression": "tiff_adobe_deflate"} if argument: arguments["strip_size"] = 2**18 im.save(out, **arguments) with Image.open(out) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) assert len(im.tag_v2[STRIPOFFSETS]) == 1 finally: TiffImagePlugin.STRIP_SIZE = 65536 @pytest.mark.parametrize("compression", ("tiff_adobe_deflate", None)) - def test_save_zero(self, compression, tmp_path): + def test_save_zero(self, compression: str | None, tmp_path: Path) -> None: im = Image.new("RGB", (0, 0)) out = str(tmp_path / "temp.tif") with pytest.raises(SystemError): im.save(out, compression=compression) - def test_save_many_compressed(self, tmp_path): + def test_save_many_compressed(self, tmp_path: Path) -> None: im = hopper() out = str(tmp_path / "temp.tif") for _ in range(10000): @@ -1132,7 +1142,7 @@ class TestFileLibTiff(LibTiffTestCase): ("Tests/images/child_ifd_jpeg.tiff", (20,)), ), ) - def test_get_child_images(self, path, sizes): + def test_get_child_images(self, path: str, sizes: tuple[int, ...]) -> None: with Image.open(path) as im: ims = im.get_child_images() diff --git a/Tests/test_file_libtiff_small.py b/Tests/test_file_libtiff_small.py index 9501c55a6..617e1e89c 100644 --- a/Tests/test_file_libtiff_small.py +++ b/Tests/test_file_libtiff_small.py @@ -1,5 +1,7 @@ from __future__ import annotations + from io import BytesIO +from pathlib import Path from PIL import Image @@ -7,7 +9,6 @@ from .test_file_libtiff import LibTiffTestCase class TestFileLibTiffSmall(LibTiffTestCase): - """The small lena image was failing on open in the libtiff decoder because the file pointer was set to the wrong place by a spurious seek. It wasn't failing with the byteio method. @@ -16,7 +17,7 @@ class TestFileLibTiffSmall(LibTiffTestCase): file just before reading in libtiff. These tests remain to ensure that it stays fixed.""" - def test_g4_hopper_file(self, tmp_path): + def test_g4_hopper_file(self, tmp_path: Path) -> None: """Testing the open file load path""" test_file = "Tests/images/hopper_g4.tif" @@ -25,7 +26,7 @@ class TestFileLibTiffSmall(LibTiffTestCase): assert im.size == (128, 128) self._assert_noerr(tmp_path, im) - def test_g4_hopper_bytesio(self, tmp_path): + def test_g4_hopper_bytesio(self, tmp_path: Path) -> None: """Testing the bytesio loading code path""" test_file = "Tests/images/hopper_g4.tif" s = BytesIO() @@ -36,7 +37,7 @@ class TestFileLibTiffSmall(LibTiffTestCase): assert im.size == (128, 128) self._assert_noerr(tmp_path, im) - def test_g4_hopper(self, tmp_path): + def test_g4_hopper(self, tmp_path: Path) -> None: """The 128x128 lena image failed for some reason.""" test_file = "Tests/images/hopper_g4.tif" diff --git a/Tests/test_file_mcidas.py b/Tests/test_file_mcidas.py index 4b31aaa78..e11e6bb52 100644 --- a/Tests/test_file_mcidas.py +++ b/Tests/test_file_mcidas.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, McIdasImagePlugin @@ -6,19 +7,19 @@ from PIL import Image, McIdasImagePlugin from .helper import assert_image_equal_tofile -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): McIdasImagePlugin.McIdasImageFile(invalid_file) -def test_valid_file(): +def test_valid_file() -> None: # Arrange # https://ghrc.nsstc.nasa.gov/hydro/details/cmx3g8 # https://ghrc.nsstc.nasa.gov/pub/fieldCampaigns/camex3/cmx3g8/browse/ test_file = "Tests/images/cmx3g8_wv_1998.260_0745_mcidas.ara" - saved_file = "Tests/images/cmx3g8_wv_1998.260_0745_mcidas.png" + saved_file = "Tests/images/cmx3g8_wv_1998.260_0745_mcidas.tiff" # Act with Image.open(test_file) as im: diff --git a/Tests/test_file_mic.py b/Tests/test_file_mic.py index e7ea39ea9..9a6f13ea3 100644 --- a/Tests/test_file_mic.py +++ b/Tests/test_file_mic.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, ImagePalette @@ -12,7 +13,7 @@ pytestmark = skip_unless_feature("libtiff") TEST_FILE = "Tests/images/hopper.mic" -def test_sanity(): +def test_sanity() -> None: with Image.open(TEST_FILE) as im: im.load() assert im.mode == "RGBA" @@ -27,22 +28,22 @@ def test_sanity(): assert_image_similar(im, im2, 10) -def test_n_frames(): +def test_n_frames() -> None: with Image.open(TEST_FILE) as im: assert im.n_frames == 1 -def test_is_animated(): +def test_is_animated() -> None: with Image.open(TEST_FILE) as im: assert not im.is_animated -def test_tell(): +def test_tell() -> None: with Image.open(TEST_FILE) as im: assert im.tell() == 0 -def test_seek(): +def test_seek() -> None: with Image.open(TEST_FILE) as im: im.seek(0) assert im.tell() == 0 @@ -52,7 +53,7 @@ def test_seek(): assert im.tell() == 0 -def test_close(): +def test_close() -> None: with Image.open(TEST_FILE) as im: pass assert im.ole.fp.closed @@ -62,7 +63,7 @@ def test_close(): assert im.ole.fp.closed -def test_invalid_file(): +def test_invalid_file() -> None: # Test an invalid OLE file invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index da62bc6d4..f105428ca 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -1,10 +1,12 @@ from __future__ import annotations + import warnings from io import BytesIO +from typing import Any, cast import pytest -from PIL import Image +from PIL import Image, MpoImagePlugin from .helper import ( assert_image_equal, @@ -18,18 +20,15 @@ test_files = ["Tests/images/sugarshack.mpo", "Tests/images/frozenpond.mpo"] pytestmark = skip_unless_feature("jpg") -def roundtrip(im, **options): +def roundtrip(im: Image.Image, **options: Any) -> MpoImagePlugin.MpoImageFile: out = BytesIO() im.save(out, "MPO", **options) - test_bytes = out.tell() out.seek(0) - im = Image.open(out) - im.bytes = test_bytes # for testing only - return im + return cast(MpoImagePlugin.MpoImageFile, Image.open(out)) @pytest.mark.parametrize("test_file", test_files) -def test_sanity(test_file): +def test_sanity(test_file: str) -> None: with Image.open(test_file) as im: im.load() assert im.mode == "RGB" @@ -38,8 +37,8 @@ def test_sanity(test_file): @pytest.mark.skipif(is_pypy(), reason="Requires CPython") -def test_unclosed_file(): - def open(): +def test_unclosed_file() -> None: + def open() -> None: im = Image.open(test_files[0]) im.load() @@ -47,14 +46,14 @@ def test_unclosed_file(): open() -def test_closed_file(): +def test_closed_file() -> None: with warnings.catch_warnings(): im = Image.open(test_files[0]) im.load() im.close() -def test_seek_after_close(): +def test_seek_after_close() -> None: im = Image.open(test_files[0]) im.close() @@ -62,14 +61,14 @@ def test_seek_after_close(): im.seek(1) -def test_context_manager(): +def test_context_manager() -> None: with warnings.catch_warnings(): with Image.open(test_files[0]) as im: im.load() @pytest.mark.parametrize("test_file", test_files) -def test_app(test_file): +def test_app(test_file: str) -> None: # Test APP/COM reader (@PIL135) with Image.open(test_file) as im: assert im.applist[0][0] == "APP1" @@ -81,7 +80,7 @@ def test_app(test_file): @pytest.mark.parametrize("test_file", test_files) -def test_exif(test_file): +def test_exif(test_file: str) -> None: with Image.open(test_file) as im_original: im_reloaded = roundtrip(im_original, save_all=True, exif=im_original.getexif()) @@ -92,7 +91,7 @@ def test_exif(test_file): assert info[34665] == 188 -def test_frame_size(): +def test_frame_size() -> None: # This image has been hexedited to contain a different size # in the EXIF data of the second frame with Image.open("Tests/images/sugarshack_frame_size.mpo") as im: @@ -105,7 +104,7 @@ def test_frame_size(): assert im.size == (640, 480) -def test_ignore_frame_size(): +def test_ignore_frame_size() -> None: # Ignore the different size of the second frame # since this is not a "Large Thumbnail" image with Image.open("Tests/images/ignore_frame_size.mpo") as im: @@ -119,7 +118,7 @@ def test_ignore_frame_size(): assert im.size == (64, 64) -def test_parallax(): +def test_parallax() -> None: # Nintendo with Image.open("Tests/images/sugarshack.mpo") as im: exif = im.getexif() @@ -132,7 +131,7 @@ def test_parallax(): assert exif.get_ifd(0x927C)[0xB211] == -3.125 -def test_reload_exif_after_seek(): +def test_reload_exif_after_seek() -> None: with Image.open("Tests/images/sugarshack.mpo") as im: exif = im.getexif() del exif[296] @@ -142,14 +141,14 @@ def test_reload_exif_after_seek(): @pytest.mark.parametrize("test_file", test_files) -def test_mp(test_file): +def test_mp(test_file: str) -> None: with Image.open(test_file) as im: mpinfo = im._getmp() assert mpinfo[45056] == b"0100" assert mpinfo[45057] == 2 -def test_mp_offset(): +def test_mp_offset() -> None: # This image has been manually hexedited to have an IFD offset of 10 # in APP2 data, in contrast to normal 8 with Image.open("Tests/images/sugarshack_ifd_offset.mpo") as im: @@ -158,7 +157,7 @@ def test_mp_offset(): assert mpinfo[45057] == 2 -def test_mp_no_data(): +def test_mp_no_data() -> None: # This image has been manually hexedited to have the second frame # beyond the end of the file with Image.open("Tests/images/sugarshack_no_data.mpo") as im: @@ -167,7 +166,7 @@ def test_mp_no_data(): @pytest.mark.parametrize("test_file", test_files) -def test_mp_attribute(test_file): +def test_mp_attribute(test_file: str) -> None: with Image.open(test_file) as im: mpinfo = im._getmp() for frame_number, mpentry in enumerate(mpinfo[0xB002]): @@ -184,7 +183,7 @@ def test_mp_attribute(test_file): @pytest.mark.parametrize("test_file", test_files) -def test_seek(test_file): +def test_seek(test_file: str) -> None: with Image.open(test_file) as im: assert im.tell() == 0 # prior to first image raises an error, both blatant and borderline @@ -208,13 +207,13 @@ def test_seek(test_file): assert im.tell() == 0 -def test_n_frames(): +def test_n_frames() -> None: with Image.open("Tests/images/sugarshack.mpo") as im: assert im.n_frames == 2 assert im.is_animated -def test_eoferror(): +def test_eoferror() -> None: with Image.open("Tests/images/sugarshack.mpo") as im: n_frames = im.n_frames @@ -228,7 +227,7 @@ def test_eoferror(): @pytest.mark.parametrize("test_file", test_files) -def test_image_grab(test_file): +def test_image_grab(test_file: str) -> None: with Image.open(test_file) as im: assert im.tell() == 0 im0 = im.tobytes() @@ -243,7 +242,7 @@ def test_image_grab(test_file): @pytest.mark.parametrize("test_file", test_files) -def test_save(test_file): +def test_save(test_file: str) -> None: with Image.open(test_file) as im: assert im.tell() == 0 jpg0 = roundtrip(im) @@ -254,7 +253,7 @@ def test_save(test_file): assert_image_similar(im, jpg1, 30) -def test_save_all(): +def test_save_all() -> None: for test_file in test_files: with Image.open(test_file) as im: im_reloaded = roundtrip(im, save_all=True) diff --git a/Tests/test_file_msp.py b/Tests/test_file_msp.py index f4e357ae0..b0964aabe 100644 --- a/Tests/test_file_msp.py +++ b/Tests/test_file_msp.py @@ -1,5 +1,7 @@ from __future__ import annotations + import os +from pathlib import Path import pytest @@ -12,7 +14,7 @@ EXTRA_DIR = "Tests/images/picins" YA_EXTRA_DIR = "Tests/images/msp" -def test_sanity(tmp_path): +def test_sanity(tmp_path: Path) -> None: test_file = str(tmp_path / "temp.msp") hopper("1").save(test_file) @@ -24,14 +26,14 @@ def test_sanity(tmp_path): assert im.format == "MSP" -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): MspImagePlugin.MspImageFile(invalid_file) -def test_bad_checksum(): +def test_bad_checksum() -> None: # Arrange # This was created by forcing Pillow to save with checksum=0 bad_checksum = "Tests/images/hopper_bad_checksum.msp" @@ -41,7 +43,7 @@ def test_bad_checksum(): MspImagePlugin.MspImageFile(bad_checksum) -def test_open_windows_v1(): +def test_open_windows_v1() -> None: # Arrange # Act with Image.open(TEST_FILE) as im: @@ -50,7 +52,7 @@ def test_open_windows_v1(): assert isinstance(im, MspImagePlugin.MspImageFile) -def _assert_file_image_equal(source_path, target_path): +def _assert_file_image_equal(source_path: str, target_path: str) -> None: with Image.open(source_path) as im: assert_image_equal_tofile(im, target_path) @@ -58,7 +60,7 @@ def _assert_file_image_equal(source_path, target_path): @pytest.mark.skipif( not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" ) -def test_open_windows_v2(): +def test_open_windows_v2() -> None: files = ( os.path.join(EXTRA_DIR, f) for f in os.listdir(EXTRA_DIR) @@ -71,7 +73,7 @@ def test_open_windows_v2(): @pytest.mark.skipif( not os.path.exists(YA_EXTRA_DIR), reason="Even More Extra image files not installed" ) -def test_msp_v2(): +def test_msp_v2() -> None: for f in os.listdir(YA_EXTRA_DIR): if ".MSP" not in f: continue @@ -79,7 +81,7 @@ def test_msp_v2(): _assert_file_image_equal(path, path.replace(".MSP", ".png")) -def test_cannot_save_wrong_mode(tmp_path): +def test_cannot_save_wrong_mode(tmp_path: Path) -> None: # Arrange im = hopper() filename = str(tmp_path / "temp.msp") diff --git a/Tests/test_file_palm.py b/Tests/test_file_palm.py index 735840de4..194f39b30 100644 --- a/Tests/test_file_palm.py +++ b/Tests/test_file_palm.py @@ -1,6 +1,8 @@ from __future__ import annotations + import os.path import subprocess +from pathlib import Path import pytest @@ -9,7 +11,7 @@ from PIL import Image from .helper import assert_image_equal, hopper, magick_command -def helper_save_as_palm(tmp_path, mode): +def helper_save_as_palm(tmp_path: Path, mode: str) -> None: # Arrange im = hopper(mode) outfile = str(tmp_path / ("temp_" + mode + ".palm")) @@ -22,7 +24,7 @@ def helper_save_as_palm(tmp_path, mode): assert os.path.getsize(outfile) > 0 -def open_with_magick(magick, tmp_path, f): +def open_with_magick(magick: list[str], tmp_path: Path, f: str) -> Image.Image: outfile = str(tmp_path / "temp.png") rc = subprocess.call( magick + [f, outfile], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT @@ -31,7 +33,7 @@ def open_with_magick(magick, tmp_path, f): return Image.open(outfile) -def roundtrip(tmp_path, mode): +def roundtrip(tmp_path: Path, mode: str) -> None: magick = magick_command() if not magick: return @@ -44,7 +46,7 @@ def roundtrip(tmp_path, mode): assert_image_equal(converted, im) -def test_monochrome(tmp_path): +def test_monochrome(tmp_path: Path) -> None: # Arrange mode = "1" @@ -54,7 +56,7 @@ def test_monochrome(tmp_path): @pytest.mark.xfail(reason="Palm P image is wrong") -def test_p_mode(tmp_path): +def test_p_mode(tmp_path: Path) -> None: # Arrange mode = "P" @@ -64,6 +66,6 @@ def test_p_mode(tmp_path): @pytest.mark.parametrize("mode", ("L", "RGB")) -def test_oserror(tmp_path, mode): +def test_oserror(tmp_path: Path, mode: str) -> None: with pytest.raises(OSError): helper_save_as_palm(tmp_path, mode) diff --git a/Tests/test_file_pcd.py b/Tests/test_file_pcd.py index 596a3414f..81a316fc1 100644 --- a/Tests/test_file_pcd.py +++ b/Tests/test_file_pcd.py @@ -1,8 +1,9 @@ from __future__ import annotations + from PIL import Image -def test_load_raw(): +def test_load_raw() -> None: with Image.open("Tests/images/hopper.pcd") as im: im.load() # should not segfault. diff --git a/Tests/test_file_pcx.py b/Tests/test_file_pcx.py index f42ec4a68..ab9f9663e 100644 --- a/Tests/test_file_pcx.py +++ b/Tests/test_file_pcx.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import Image, ImageFile, PcxImagePlugin @@ -6,7 +9,7 @@ from PIL import Image, ImageFile, PcxImagePlugin from .helper import assert_image_equal, hopper -def _roundtrip(tmp_path, im): +def _roundtrip(tmp_path: Path, im: Image.Image) -> None: f = str(tmp_path / "temp.pcx") im.save(f) with Image.open(f) as im2: @@ -17,7 +20,7 @@ def _roundtrip(tmp_path, im): assert_image_equal(im2, im) -def test_sanity(tmp_path): +def test_sanity(tmp_path: Path) -> None: for mode in ("1", "L", "P", "RGB"): _roundtrip(tmp_path, hopper(mode)) @@ -33,7 +36,7 @@ def test_sanity(tmp_path): im.save(f) -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): @@ -41,7 +44,7 @@ def test_invalid_file(): @pytest.mark.parametrize("mode", ("1", "L", "P", "RGB")) -def test_odd(tmp_path, mode): +def test_odd(tmp_path: Path, mode: str) -> None: # See issue #523, odd sized images should have a stride that's even. # Not that ImageMagick or GIMP write PCX that way. # We were not handling properly. @@ -50,7 +53,7 @@ def test_odd(tmp_path, mode): _roundtrip(tmp_path, hopper(mode).resize((511, 511))) -def test_odd_read(): +def test_odd_read() -> None: # Reading an image with an odd stride, making it malformed with Image.open("Tests/images/odd_stride.pcx") as im: im.load() @@ -58,7 +61,7 @@ def test_odd_read(): assert im.size == (371, 150) -def test_pil184(): +def test_pil184() -> None: # Check reading of files where xmin/xmax is not zero. test_file = "Tests/images/pil184.pcx" @@ -70,7 +73,7 @@ def test_pil184(): assert im.histogram()[0] + im.histogram()[255] == 447 * 144 -def test_1px_width(tmp_path): +def test_1px_width(tmp_path: Path) -> None: im = Image.new("L", (1, 256)) px = im.load() for y in range(256): @@ -78,7 +81,7 @@ def test_1px_width(tmp_path): _roundtrip(tmp_path, im) -def test_large_count(tmp_path): +def test_large_count(tmp_path: Path) -> None: im = Image.new("L", (256, 1)) px = im.load() for x in range(256): @@ -86,7 +89,7 @@ def test_large_count(tmp_path): _roundtrip(tmp_path, im) -def _test_buffer_overflow(tmp_path, im, size=1024): +def _test_buffer_overflow(tmp_path: Path, im: Image.Image, size: int = 1024) -> None: _last = ImageFile.MAXBLOCK ImageFile.MAXBLOCK = size try: @@ -95,7 +98,7 @@ def _test_buffer_overflow(tmp_path, im, size=1024): ImageFile.MAXBLOCK = _last -def test_break_in_count_overflow(tmp_path): +def test_break_in_count_overflow(tmp_path: Path) -> None: im = Image.new("L", (256, 5)) px = im.load() for y in range(4): @@ -104,7 +107,7 @@ def test_break_in_count_overflow(tmp_path): _test_buffer_overflow(tmp_path, im) -def test_break_one_in_loop(tmp_path): +def test_break_one_in_loop(tmp_path: Path) -> None: im = Image.new("L", (256, 5)) px = im.load() for y in range(5): @@ -113,7 +116,7 @@ def test_break_one_in_loop(tmp_path): _test_buffer_overflow(tmp_path, im) -def test_break_many_in_loop(tmp_path): +def test_break_many_in_loop(tmp_path: Path) -> None: im = Image.new("L", (256, 5)) px = im.load() for y in range(4): @@ -124,7 +127,7 @@ def test_break_many_in_loop(tmp_path): _test_buffer_overflow(tmp_path, im) -def test_break_one_at_end(tmp_path): +def test_break_one_at_end(tmp_path: Path) -> None: im = Image.new("L", (256, 5)) px = im.load() for y in range(5): @@ -134,7 +137,7 @@ def test_break_one_at_end(tmp_path): _test_buffer_overflow(tmp_path, im) -def test_break_many_at_end(tmp_path): +def test_break_many_at_end(tmp_path: Path) -> None: im = Image.new("L", (256, 5)) px = im.load() for y in range(5): @@ -146,7 +149,7 @@ def test_break_many_at_end(tmp_path): _test_buffer_overflow(tmp_path, im) -def test_break_padding(tmp_path): +def test_break_padding(tmp_path: Path) -> None: im = Image.new("L", (257, 5)) px = im.load() for y in range(5): diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index 9e07d9ed0..d39a86565 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -1,9 +1,12 @@ from __future__ import annotations + import io import os import os.path import tempfile import time +from pathlib import Path +from typing import Any, Generator import pytest @@ -12,7 +15,7 @@ from PIL import Image, PdfParser, features from .helper import hopper, mark_if_feature_version, skip_unless_feature -def helper_save_as_pdf(tmp_path, mode, **kwargs): +def helper_save_as_pdf(tmp_path: Path, mode: str, **kwargs: Any) -> str: # Arrange im = hopper(mode) outfile = str(tmp_path / ("temp_" + mode + ".pdf")) @@ -39,17 +42,17 @@ def helper_save_as_pdf(tmp_path, mode, **kwargs): @pytest.mark.parametrize("mode", ("L", "P", "RGB", "CMYK")) -def test_save(tmp_path, mode): +def test_save(tmp_path: Path, mode: str) -> None: 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): +def test_save_alpha(tmp_path: Path, mode: str) -> None: helper_save_as_pdf(tmp_path, mode) -def test_p_alpha(tmp_path): +def test_p_alpha(tmp_path: Path) -> None: # Arrange outfile = str(tmp_path / "temp.pdf") with Image.open("Tests/images/pil123p.png") as im: @@ -65,7 +68,7 @@ def test_p_alpha(tmp_path): assert b"\n/SMask " in contents -def test_monochrome(tmp_path): +def test_monochrome(tmp_path: Path) -> None: # Arrange mode = "1" @@ -74,7 +77,7 @@ def test_monochrome(tmp_path): assert os.path.getsize(outfile) < (5000 if features.check("libtiff") else 15000) -def test_unsupported_mode(tmp_path): +def test_unsupported_mode(tmp_path: Path) -> None: im = hopper("PA") outfile = str(tmp_path / "temp_PA.pdf") @@ -82,7 +85,7 @@ def test_unsupported_mode(tmp_path): im.save(outfile) -def test_resolution(tmp_path): +def test_resolution(tmp_path: Path) -> None: im = hopper() outfile = str(tmp_path / "temp.pdf") @@ -110,7 +113,7 @@ def test_resolution(tmp_path): {"dpi": (75, 150), "resolution": 200}, ), ) -def test_dpi(params, tmp_path): +def test_dpi(params: dict[str, int | tuple[int, int]], tmp_path: Path) -> None: im = hopper() outfile = str(tmp_path / "temp.pdf") @@ -134,7 +137,7 @@ def test_dpi(params, tmp_path): @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) -def test_save_all(tmp_path): +def test_save_all(tmp_path: Path) -> None: # Single frame image helper_save_as_pdf(tmp_path, "RGB", save_all=True) @@ -154,7 +157,7 @@ def test_save_all(tmp_path): assert os.path.getsize(outfile) > 0 # Test appending using a generator - def im_generator(ims): + def im_generator(ims: list[Image.Image]) -> Generator[Image.Image, None, None]: yield from ims im.save(outfile, save_all=True, append_images=im_generator(ims)) @@ -170,7 +173,7 @@ def test_save_all(tmp_path): assert os.path.getsize(outfile) > 0 -def test_multiframe_normal_save(tmp_path): +def test_multiframe_normal_save(tmp_path: Path) -> None: # Test saving a multiframe image without save_all with Image.open("Tests/images/dispose_bgnd.gif") as im: outfile = str(tmp_path / "temp.pdf") @@ -180,7 +183,7 @@ def test_multiframe_normal_save(tmp_path): assert os.path.getsize(outfile) > 0 -def test_pdf_open(tmp_path): +def test_pdf_open(tmp_path: Path) -> None: # fail on a buffer full of null bytes with pytest.raises(PdfParser.PdfFormatError): PdfParser.PdfParser(buf=bytearray(65536)) @@ -217,14 +220,14 @@ def test_pdf_open(tmp_path): assert not hopper_pdf.should_close_file -def test_pdf_append_fails_on_nonexistent_file(): +def test_pdf_append_fails_on_nonexistent_file() -> None: im = hopper("RGB") with tempfile.TemporaryDirectory() as temp_dir: with pytest.raises(OSError): im.save(os.path.join(temp_dir, "nonexistent.pdf"), append=True) -def check_pdf_pages_consistency(pdf): +def check_pdf_pages_consistency(pdf: PdfParser.PdfParser) -> None: pages_info = pdf.read_indirect(pdf.pages_ref) assert b"Parent" not in pages_info assert b"Kids" in pages_info @@ -242,7 +245,7 @@ def check_pdf_pages_consistency(pdf): assert kids_not_used == [] -def test_pdf_append(tmp_path): +def test_pdf_append(tmp_path: Path) -> None: # make a PDF file pdf_filename = helper_save_as_pdf(tmp_path, "RGB", producer="PdfParser") @@ -293,7 +296,7 @@ def test_pdf_append(tmp_path): check_pdf_pages_consistency(pdf) -def test_pdf_info(tmp_path): +def test_pdf_info(tmp_path: Path) -> None: # make a PDF file pdf_filename = helper_save_as_pdf( tmp_path, @@ -322,7 +325,7 @@ def test_pdf_info(tmp_path): check_pdf_pages_consistency(pdf) -def test_pdf_append_to_bytesio(): +def test_pdf_append_to_bytesio() -> None: im = hopper("RGB") f = io.BytesIO() im.save(f, format="PDF") @@ -337,7 +340,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): +def test_redos(newline: bytes) -> None: malicious = b" trailer<<>>" + newline * 3456 # This particular exception isn't relevant here. diff --git a/Tests/test_file_pixar.py b/Tests/test_file_pixar.py index 63779f202..8f208cfbf 100644 --- a/Tests/test_file_pixar.py +++ b/Tests/test_file_pixar.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, PixarImagePlugin @@ -8,7 +9,7 @@ from .helper import assert_image_similar, hopper TEST_FILE = "Tests/images/hopper.pxr" -def test_sanity(): +def test_sanity() -> None: with Image.open(TEST_FILE) as im: im.load() assert im.mode == "RGB" @@ -20,7 +21,7 @@ def test_sanity(): assert_image_similar(im, im2, 4.8) -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index ff3862110..30fb14c44 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -1,9 +1,13 @@ from __future__ import annotations + import re import sys import warnings import zlib from io import BytesIO +from pathlib import Path +from types import ModuleType +from typing import Any, cast import pytest @@ -20,6 +24,7 @@ from .helper import ( skip_unless_feature, ) +ElementTree: ModuleType | None try: from defusedxml import ElementTree except ImportError: @@ -34,7 +39,7 @@ TEST_PNG_FILE = "Tests/images/hopper.png" MAGIC = PngImagePlugin._MAGIC -def chunk(cid, *data): +def chunk(cid: bytes, *data: bytes) -> bytes: test_file = BytesIO() PngImagePlugin.putchunk(*(test_file, cid) + data) return test_file.getvalue() @@ -50,20 +55,20 @@ HEAD = MAGIC + IHDR TAIL = IDAT + IEND -def load(data): +def load(data: bytes) -> Image.Image: return Image.open(BytesIO(data)) -def roundtrip(im, **options): +def roundtrip(im: Image.Image, **options: Any) -> PngImagePlugin.PngImageFile: out = BytesIO() im.save(out, "PNG", **options) out.seek(0) - return Image.open(out) + return cast(PngImagePlugin.PngImageFile, Image.open(out)) @skip_unless_feature("zlib") class TestFilePng: - def get_chunks(self, filename): + def get_chunks(self, filename: str) -> list[bytes]: chunks = [] with open(filename, "rb") as fp: fp.read(8) @@ -78,7 +83,7 @@ class TestFilePng: png.crc(cid, s) return chunks - def test_sanity(self, tmp_path): + def test_sanity(self, tmp_path: Path) -> None: # internal version number assert re.search(r"\d+(\.\d+){1,3}$", features.version_codec("zlib")) @@ -97,17 +102,17 @@ class TestFilePng: im = hopper(mode) im.save(test_file) with Image.open(test_file) as reloaded: - if mode in ("I;16", "I;16B"): + if mode in ("I", "I;16B"): reloaded = reloaded.convert(mode) assert_image_equal(reloaded, im) - def test_invalid_file(self): + def test_invalid_file(self) -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): PngImagePlugin.PngImageFile(invalid_file) - def test_broken(self): + def test_broken(self) -> None: # Check reading of totally broken files. In this case, the test # file was checked into Subversion as a text file. @@ -116,7 +121,7 @@ class TestFilePng: with Image.open(test_file): pass - def test_bad_text(self): + def test_bad_text(self) -> None: # Make sure PIL can read malformed tEXt chunks (@PIL152) im = load(HEAD + chunk(b"tEXt") + TAIL) @@ -134,7 +139,7 @@ class TestFilePng: im = load(HEAD + chunk(b"tEXt", b"spam\0egg\0") + TAIL) assert im.info == {"spam": "egg\x00"} - def test_bad_ztxt(self): + def test_bad_ztxt(self) -> None: # Test reading malformed zTXt chunks (python-pillow/Pillow#318) im = load(HEAD + chunk(b"zTXt") + TAIL) @@ -155,7 +160,7 @@ class TestFilePng: im = load(HEAD + chunk(b"zTXt", b"spam\0\0" + zlib.compress(b"egg")) + TAIL) assert im.info == {"spam": "egg"} - def test_bad_itxt(self): + def test_bad_itxt(self) -> None: im = load(HEAD + chunk(b"iTXt") + TAIL) assert im.info == {} @@ -199,7 +204,7 @@ class TestFilePng: assert im.info["spam"].lang == "en" assert im.info["spam"].tkey == "Spam" - def test_interlace(self): + def test_interlace(self) -> None: test_file = "Tests/images/pil123p.png" with Image.open(test_file) as im: assert_image(im, "P", (162, 150)) @@ -214,7 +219,7 @@ class TestFilePng: im.load() - def test_load_transparent_p(self): + def test_load_transparent_p(self) -> None: test_file = "Tests/images/pil123p.png" with Image.open(test_file) as im: assert_image(im, "P", (162, 150)) @@ -224,7 +229,7 @@ class TestFilePng: # image has 124 unique alpha values assert len(im.getchannel("A").getcolors()) == 124 - def test_load_transparent_rgb(self): + def test_load_transparent_rgb(self) -> None: test_file = "Tests/images/rgb_trns.png" with Image.open(test_file) as im: assert im.info["transparency"] == (0, 255, 52) @@ -236,7 +241,7 @@ class TestFilePng: # image has 876 transparent pixels assert im.getchannel("A").getcolors()[0][0] == 876 - def test_save_p_transparent_palette(self, tmp_path): + def test_save_p_transparent_palette(self, tmp_path: Path) -> None: in_file = "Tests/images/pil123p.png" with Image.open(in_file) as im: # 'transparency' contains a byte string with the opacity for @@ -257,7 +262,7 @@ class TestFilePng: # image has 124 unique alpha values assert len(im.getchannel("A").getcolors()) == 124 - def test_save_p_single_transparency(self, tmp_path): + def test_save_p_single_transparency(self, tmp_path: Path) -> None: in_file = "Tests/images/p_trns_single.png" with Image.open(in_file) as im: # pixel value 164 is full transparent @@ -280,7 +285,7 @@ class TestFilePng: # image has 876 transparent pixels assert im.getchannel("A").getcolors()[0][0] == 876 - def test_save_p_transparent_black(self, tmp_path): + def test_save_p_transparent_black(self, tmp_path: Path) -> None: # check if solid black image with full transparency # is supported (check for #1838) im = Image.new("RGBA", (10, 10), (0, 0, 0, 0)) @@ -298,9 +303,9 @@ class TestFilePng: assert_image(im, "RGBA", (10, 10)) assert im.getcolors() == [(100, (0, 0, 0, 0))] - 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" + def test_save_grayscale_transparency(self, tmp_path: Path) -> None: + for mode, num_transparent in {"1": 1994, "L": 559, "I;16": 559}.items(): + in_file = "Tests/images/" + mode.split(";")[0].lower() + "_trns.png" with Image.open(in_file) as im: assert im.mode == mode assert im.info["transparency"] == 255 @@ -319,13 +324,13 @@ class TestFilePng: test_im_rgba = test_im.convert("RGBA") assert test_im_rgba.getchannel("A").getcolors()[0][0] == num_transparent - def test_save_rgb_single_transparency(self, tmp_path): + def test_save_rgb_single_transparency(self, tmp_path: Path) -> None: in_file = "Tests/images/caption_6_33_22.png" with Image.open(in_file) as im: test_file = str(tmp_path / "temp.png") im.save(test_file) - def test_load_verify(self): + def test_load_verify(self) -> None: # Check open/load/verify exception (@PIL150) with Image.open(TEST_PNG_FILE) as im: @@ -338,7 +343,7 @@ class TestFilePng: with pytest.raises(RuntimeError): im.verify() - def test_verify_struct_error(self): + def test_verify_struct_error(self) -> None: # Check open/load/verify exception (#1755) # offsets to test, -10: breaks in i32() in read. (OSError) @@ -354,7 +359,7 @@ class TestFilePng: with pytest.raises((OSError, SyntaxError)): im.verify() - def test_verify_ignores_crc_error(self): + def test_verify_ignores_crc_error(self) -> None: # check ignores crc errors in ancillary chunks chunk_data = chunk(b"tEXt", b"spam") @@ -371,7 +376,7 @@ class TestFilePng: finally: ImageFile.LOAD_TRUNCATED_IMAGES = False - def test_verify_not_ignores_crc_error_in_required_chunk(self): + def test_verify_not_ignores_crc_error_in_required_chunk(self) -> None: # check does not ignore crc errors in required chunks image_data = MAGIC + IHDR[:-1] + b"q" + TAIL @@ -383,18 +388,18 @@ class TestFilePng: finally: ImageFile.LOAD_TRUNCATED_IMAGES = False - def test_roundtrip_dpi(self): + def test_roundtrip_dpi(self) -> None: # Check dpi roundtripping with Image.open(TEST_PNG_FILE) as im: im = roundtrip(im, dpi=(100.33, 100.33)) assert im.info["dpi"] == (100.33, 100.33) - def test_load_float_dpi(self): + def test_load_float_dpi(self) -> None: with Image.open(TEST_PNG_FILE) as im: assert im.info["dpi"] == (95.9866, 95.9866) - def test_roundtrip_text(self): + def test_roundtrip_text(self) -> None: # Check text roundtripping with Image.open(TEST_PNG_FILE) as im: @@ -406,7 +411,7 @@ class TestFilePng: assert im.info == {"TXT": "VALUE", "ZIP": "VALUE"} assert im.text == {"TXT": "VALUE", "ZIP": "VALUE"} - def test_roundtrip_itxt(self): + def test_roundtrip_itxt(self) -> None: # Check iTXt roundtripping im = Image.new("RGB", (32, 32)) @@ -422,7 +427,7 @@ class TestFilePng: assert im.text["eggs"].lang == "en" assert im.text["eggs"].tkey == "Eggs" - def test_nonunicode_text(self): + def test_nonunicode_text(self) -> None: # Check so that non-Unicode text is saved as a tEXt rather than iTXt im = Image.new("RGB", (32, 32)) @@ -431,10 +436,10 @@ class TestFilePng: im = roundtrip(im, pnginfo=info) assert isinstance(im.info["Text"], str) - def test_unicode_text(self): + def test_unicode_text(self) -> None: # Check preservation of non-ASCII characters - def rt_text(value): + def rt_text(value: str) -> None: im = Image.new("RGB", (32, 32)) info = PngImagePlugin.PngInfo() info.add_text("Text", value) @@ -447,7 +452,7 @@ class TestFilePng: rt_text(chr(0x4E00) + chr(0x66F0) + chr(0x9FBA) + chr(0x3042) + chr(0xAC00)) rt_text("A" + chr(0xC4) + chr(0x472) + chr(0x3042)) # Combined - def test_scary(self): + def test_scary(self) -> None: # Check reading of evil PNG file. For information, see: # http://scary.beasts.org/security/CESA-2004-001.txt # The first byte is removed from pngtest_bad.png @@ -461,7 +466,7 @@ class TestFilePng: with Image.open(pngfile): pass - def test_trns_rgb(self): + def test_trns_rgb(self) -> None: # Check writing and reading of tRNS chunks for RGB images. # Independent file sample provided by Sebastian Spaeth. @@ -476,7 +481,7 @@ class TestFilePng: im = roundtrip(im, transparency=(0, 1, 2)) assert im.info["transparency"] == (0, 1, 2) - def test_trns_p(self, tmp_path): + def test_trns_p(self, tmp_path: Path) -> None: # Check writing a transparency of 0, issue #528 im = hopper("P") im.info["transparency"] = 0 @@ -489,13 +494,13 @@ class TestFilePng: assert_image_equal(im2.convert("RGBA"), im.convert("RGBA")) - def test_trns_null(self): + def test_trns_null(self) -> None: # 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): + def test_save_icc_profile(self) -> None: with Image.open("Tests/images/icc_profile_none.png") as im: assert im.info["icc_profile"] is None @@ -505,40 +510,40 @@ class TestFilePng: im = roundtrip(im, icc_profile=expected_icc) assert im.info["icc_profile"] == expected_icc - def test_discard_icc_profile(self): + def test_discard_icc_profile(self) -> None: with Image.open("Tests/images/icc_profile.png") as im: assert "icc_profile" in im.info im = roundtrip(im, icc_profile=None) assert "icc_profile" not in im.info - def test_roundtrip_icc_profile(self): + def test_roundtrip_icc_profile(self) -> None: with Image.open("Tests/images/icc_profile.png") as im: expected_icc = im.info["icc_profile"] im = roundtrip(im) assert im.info["icc_profile"] == expected_icc - def test_roundtrip_no_icc_profile(self): + def test_roundtrip_no_icc_profile(self) -> None: with Image.open("Tests/images/icc_profile_none.png") as im: assert im.info["icc_profile"] is None im = roundtrip(im) assert "icc_profile" not in im.info - def test_repr_png(self): + def test_repr_png(self) -> None: im = hopper() with Image.open(BytesIO(im._repr_png_())) as repr_png: assert repr_png.format == "PNG" assert_image_equal(im, repr_png) - def test_repr_png_error_returns_none(self): + def test_repr_png_error_returns_none(self) -> None: im = hopper("F") assert im._repr_png_() is None - def test_chunk_order(self, tmp_path): + def test_chunk_order(self, tmp_path: Path) -> None: with Image.open("Tests/images/icc_profile.png") as im: test_file = str(tmp_path / "temp.png") im.convert("P").save(test_file, dpi=(100, 100)) @@ -559,17 +564,17 @@ class TestFilePng: # pHYs - before IDAT assert chunks.index(b"pHYs") < chunks.index(b"IDAT") - def test_getchunks(self): + def test_getchunks(self) -> None: im = hopper() chunks = PngImagePlugin.getchunks(im) assert len(chunks) == 3 - def test_read_private_chunks(self): + def test_read_private_chunks(self) -> None: with Image.open("Tests/images/exif.png") as im: assert im.private_chunks == [(b"orNT", b"\x01")] - def test_roundtrip_private_chunk(self): + def test_roundtrip_private_chunk(self) -> None: # Check private chunk roundtripping with Image.open(TEST_PNG_FILE) as im: @@ -587,7 +592,7 @@ class TestFilePng: (b"prIV", b"VALUE3", True), ] - def test_textual_chunks_after_idat(self): + def test_textual_chunks_after_idat(self) -> None: with Image.open("Tests/images/hopper.png") as im: assert "comment" in im.text for k, v in { @@ -614,7 +619,11 @@ class TestFilePng: with Image.open("Tests/images/hopper_idat_after_image_end.png") as im: assert im.text == {"TXT": "VALUE", "ZIP": "VALUE"} - def test_padded_idat(self): + def test_unknown_compression_method(self) -> None: + with pytest.raises(SyntaxError, match="Unknown compression method"): + PngImagePlugin.PngImageFile("Tests/images/unknown_compression_method.png") + + def test_padded_idat(self) -> None: # This image has been manually hexedited # so that the IDAT chunk has padding at the end # Set MAXBLOCK to the length of the actual data @@ -634,7 +643,7 @@ class TestFilePng: @pytest.mark.parametrize( "cid", (b"IHDR", b"sRGB", b"pHYs", b"acTL", b"fcTL", b"fdAT") ) - def test_truncated_chunks(self, cid): + def test_truncated_chunks(self, cid: bytes) -> None: fp = BytesIO() with PngImagePlugin.PngStream(fp) as png: with pytest.raises(ValueError): @@ -644,7 +653,7 @@ class TestFilePng: png.call(cid, 0, 0) ImageFile.LOAD_TRUNCATED_IMAGES = False - def test_specify_bits(self, tmp_path): + def test_specify_bits(self, tmp_path: Path) -> None: im = hopper("P") out = str(tmp_path / "temp.png") @@ -653,7 +662,7 @@ class TestFilePng: with Image.open(out) as reloaded: assert len(reloaded.png.im_palette[1]) == 48 - def test_plte_length(self, tmp_path): + def test_plte_length(self, tmp_path: Path) -> None: im = Image.new("P", (1, 1)) im.putpalette((1, 1, 1)) @@ -663,7 +672,7 @@ class TestFilePng: with Image.open(out) as reloaded: assert len(reloaded.png.im_palette[1]) == 3 - def test_getxmp(self): + def test_getxmp(self) -> None: with Image.open("Tests/images/color_snakes.png") as im: if ElementTree is None: with pytest.warns( @@ -678,7 +687,7 @@ class TestFilePng: assert description["PixelXDimension"] == "10" assert description["subject"]["Seq"] is None - def test_exif(self): + def test_exif(self) -> None: # With an EXIF chunk with Image.open("Tests/images/exif.png") as im: exif = im._getexif() @@ -704,7 +713,7 @@ class TestFilePng: exif = im.getexif() assert exif[274] == 3 - def test_exif_save(self, tmp_path): + def test_exif_save(self, tmp_path: Path) -> None: # Test exif is not saved from info test_file = str(tmp_path / "temp.png") with Image.open("Tests/images/exif.png") as im: @@ -724,7 +733,7 @@ class TestFilePng: @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_exif_from_jpg(self, tmp_path): + def test_exif_from_jpg(self, tmp_path: Path) -> None: with Image.open("Tests/images/pil_sample_rgb.jpg") as im: test_file = str(tmp_path / "temp.png") im.save(test_file, exif=im.getexif()) @@ -733,7 +742,7 @@ class TestFilePng: exif = reloaded._getexif() assert exif[305] == "Adobe Photoshop CS Macintosh" - def test_exif_argument(self, tmp_path): + def test_exif_argument(self, tmp_path: Path) -> None: with Image.open(TEST_PNG_FILE) as im: test_file = str(tmp_path / "temp.png") im.save(test_file, exif=b"exifstring") @@ -741,11 +750,11 @@ class TestFilePng: with Image.open(test_file) as reloaded: assert reloaded.info["exif"] == b"Exif\x00\x00exifstring" - def test_tell(self): + def test_tell(self) -> None: with Image.open(TEST_PNG_FILE) as im: assert im.tell() == 0 - def test_seek(self): + def test_seek(self) -> None: with Image.open(TEST_PNG_FILE) as im: im.seek(0) @@ -753,7 +762,7 @@ class TestFilePng: im.seek(1) @pytest.mark.parametrize("buffer", (True, False)) - def test_save_stdout(self, buffer): + def test_save_stdout(self, buffer: bool) -> None: old_stdout = sys.stdout if buffer: @@ -778,6 +787,18 @@ class TestFilePng: with Image.open(mystdout) as reloaded: assert_image_equal_tofile(reloaded, TEST_PNG_FILE) + def test_truncated_end_chunk(self) -> None: + with Image.open("Tests/images/truncated_end_chunk.png") as im: + with pytest.raises(OSError): + im.load() + + ImageFile.LOAD_TRUNCATED_IMAGES = True + try: + with Image.open("Tests/images/truncated_end_chunk.png") as im: + assert_image_equal_tofile(im, "Tests/images/hopper.png") + finally: + ImageFile.LOAD_TRUNCATED_IMAGES = False + @pytest.mark.skipif(is_win32(), reason="Requires Unix or macOS") @skip_unless_feature("zlib") @@ -785,7 +806,7 @@ class TestTruncatedPngPLeaks(PillowLeakTestCase): mem_limit = 2 * 1024 # max increase in K iterations = 100 # Leak is 56k/iteration, this will leak 5.6megs - def test_leak_load(self): + def test_leak_load(self) -> None: with open("Tests/images/hopper.png", "rb") as f: DATA = BytesIO(f.read(16 * 1024)) @@ -793,7 +814,7 @@ class TestTruncatedPngPLeaks(PillowLeakTestCase): with Image.open(DATA) as im: im.load() - def core(): + def core() -> None: with Image.open(DATA) as im: im.load() diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index bb49a46d3..6a0a5a445 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -1,18 +1,25 @@ from __future__ import annotations + import sys from io import BytesIO +from pathlib import Path import pytest from PIL import Image, PpmImagePlugin -from .helper import assert_image_equal_tofile, assert_image_similar, hopper +from .helper import ( + assert_image_equal, + assert_image_equal_tofile, + assert_image_similar, + hopper, +) # sample ppm stream TEST_FILE = "Tests/images/hopper.ppm" -def test_sanity(): +def test_sanity() -> None: with Image.open(TEST_FILE) as im: assert im.mode == "RGB" assert im.size == (128, 128) @@ -63,7 +70,9 @@ def test_sanity(): ), ), ) -def test_arbitrary_maxval(data, mode, pixels): +def test_arbitrary_maxval( + data: bytes, mode: str, pixels: tuple[int | tuple[int, int, int], ...] +) -> None: fp = BytesIO(data) with Image.open(fp) as im: assert im.size == (3, 1) @@ -73,31 +82,69 @@ def test_arbitrary_maxval(data, mode, pixels): assert tuple(px[x, 0] for x in range(3)) == pixels -def test_16bit_pgm(): +def test_16bit_pgm() -> None: with Image.open("Tests/images/16_bit_binary.pgm") as im: assert im.mode == "I" assert im.size == (20, 100) assert im.get_format_mimetype() == "image/x-portable-graymap" - assert_image_equal_tofile(im, "Tests/images/16_bit_binary_pgm.png") + assert_image_equal_tofile(im, "Tests/images/16_bit_binary_pgm.tiff") -def test_16bit_pgm_write(tmp_path): +def test_16bit_pgm_write(tmp_path: Path) -> None: with Image.open("Tests/images/16_bit_binary.pgm") as im: - f = str(tmp_path / "temp.pgm") - im.save(f, "PPM") + filename = str(tmp_path / "temp.pgm") + im.save(filename, "PPM") - assert_image_equal_tofile(im, f) + assert_image_equal_tofile(im, filename) -def test_pnm(tmp_path): +def test_pnm(tmp_path: Path) -> None: with Image.open("Tests/images/hopper.pnm") as im: assert_image_similar(im, hopper(), 0.0001) - f = str(tmp_path / "temp.pnm") - im.save(f) + filename = str(tmp_path / "temp.pnm") + im.save(filename) - assert_image_equal_tofile(im, f) + assert_image_equal_tofile(im, filename) + + +def test_pfm(tmp_path: Path) -> None: + with Image.open("Tests/images/hopper.pfm") as im: + assert im.info["scale"] == 1.0 + assert_image_equal(im, hopper("F")) + + filename = str(tmp_path / "tmp.pfm") + im.save(filename) + + assert_image_equal_tofile(im, filename) + + +def test_pfm_big_endian(tmp_path: Path) -> None: + with Image.open("Tests/images/hopper_be.pfm") as im: + assert im.info["scale"] == 2.5 + assert_image_equal(im, hopper("F")) + + filename = str(tmp_path / "tmp.pfm") + im.save(filename) + + assert_image_equal_tofile(im, filename) + + +@pytest.mark.parametrize( + "data", + [ + b"Pf 1 1 NaN \0\0\0\0", + b"Pf 1 1 inf \0\0\0\0", + b"Pf 1 1 -inf \0\0\0\0", + b"Pf 1 1 0.0 \0\0\0\0", + b"Pf 1 1 -0.0 \0\0\0\0", + ], +) +def test_pfm_invalid(data: bytes) -> None: + with pytest.raises(ValueError): + with Image.open(BytesIO(data)): + pass @pytest.mark.parametrize( @@ -117,12 +164,12 @@ def test_pnm(tmp_path): ), ), ) -def test_plain(plain_path, raw_path): +def test_plain(plain_path: str, raw_path: str) -> None: with Image.open(plain_path) as im: assert_image_equal_tofile(im, raw_path) -def test_16bit_plain_pgm(): +def test_16bit_plain_pgm() -> None: # P2 with maxval 2 ** 16 - 1 with Image.open("Tests/images/hopper_16bit_plain.pgm") as im: assert im.mode == "I" @@ -141,7 +188,9 @@ def test_16bit_plain_pgm(): (b"P3\n2 2\n255", b"0 0 0 001 1 1 2 2 2 255 255 255", 10**6), ), ) -def test_plain_data_with_comment(tmp_path, header, data, comment_count): +def test_plain_data_with_comment( + tmp_path: Path, header: bytes, data: bytes, comment_count: int +) -> None: path1 = str(tmp_path / "temp1.ppm") path2 = str(tmp_path / "temp2.ppm") comment = b"# comment" * comment_count @@ -154,7 +203,7 @@ def test_plain_data_with_comment(tmp_path, header, data, comment_count): @pytest.mark.parametrize("data", (b"P1\n128 128\n", b"P3\n128 128\n255\n")) -def test_plain_truncated_data(tmp_path, data): +def test_plain_truncated_data(tmp_path: Path, data: bytes) -> None: path = str(tmp_path / "temp.ppm") with open(path, "wb") as f: f.write(data) @@ -165,7 +214,7 @@ def test_plain_truncated_data(tmp_path, data): @pytest.mark.parametrize("data", (b"P1\n128 128\n1009", b"P3\n128 128\n255\n100A")) -def test_plain_invalid_data(tmp_path, data): +def test_plain_invalid_data(tmp_path: Path, data: bytes) -> None: path = str(tmp_path / "temp.ppm") with open(path, "wb") as f: f.write(data) @@ -182,7 +231,7 @@ def test_plain_invalid_data(tmp_path, data): b"P3\n128 128\n255\n012345678910 0", # token too long ), ) -def test_plain_ppm_token_too_long(tmp_path, data): +def test_plain_ppm_token_too_long(tmp_path: Path, data: bytes) -> None: path = str(tmp_path / "temp.ppm") with open(path, "wb") as f: f.write(data) @@ -192,7 +241,7 @@ def test_plain_ppm_token_too_long(tmp_path, data): im.load() -def test_plain_ppm_value_too_large(tmp_path): +def test_plain_ppm_value_too_large(tmp_path: Path) -> None: path = str(tmp_path / "temp.ppm") with open(path, "wb") as f: f.write(b"P3\n128 128\n255\n256") @@ -202,12 +251,12 @@ def test_plain_ppm_value_too_large(tmp_path): im.load() -def test_magic(): +def test_magic() -> None: with pytest.raises(SyntaxError): PpmImagePlugin.PpmImageFile(fp=BytesIO(b"PyInvalid")) -def test_header_with_comments(tmp_path): +def test_header_with_comments(tmp_path: Path) -> None: path = str(tmp_path / "temp.ppm") with open(path, "wb") as f: f.write(b"P6 #comment\n#comment\r12#comment\r8\n128 #comment\n255\n") @@ -216,7 +265,7 @@ def test_header_with_comments(tmp_path): assert im.size == (128, 128) -def test_non_integer_token(tmp_path): +def test_non_integer_token(tmp_path: Path) -> None: path = str(tmp_path / "temp.ppm") with open(path, "wb") as f: f.write(b"P6\nTEST") @@ -226,7 +275,7 @@ def test_non_integer_token(tmp_path): pass -def test_header_token_too_long(tmp_path): +def test_header_token_too_long(tmp_path: Path) -> None: path = str(tmp_path / "temp.ppm") with open(path, "wb") as f: f.write(b"P6\n 01234567890") @@ -238,7 +287,7 @@ def test_header_token_too_long(tmp_path): assert str(e.value) == "Token too long in file header: 01234567890" -def test_truncated_file(tmp_path): +def test_truncated_file(tmp_path: Path) -> None: # Test EOF in header path = str(tmp_path / "temp.pgm") with open(path, "wb") as f: @@ -257,7 +306,7 @@ def test_truncated_file(tmp_path): im.load() -def test_not_enough_image_data(tmp_path): +def test_not_enough_image_data(tmp_path: Path) -> None: path = str(tmp_path / "temp.ppm") with open(path, "wb") as f: f.write(b"P2 1 2 255 255") @@ -268,7 +317,7 @@ def test_not_enough_image_data(tmp_path): @pytest.mark.parametrize("maxval", (b"0", b"65536")) -def test_invalid_maxval(maxval, tmp_path): +def test_invalid_maxval(maxval: bytes, tmp_path: Path) -> None: path = str(tmp_path / "temp.ppm") with open(path, "wb") as f: f.write(b"P6\n3 1 " + maxval) @@ -280,7 +329,7 @@ def test_invalid_maxval(maxval, tmp_path): assert str(e.value) == "maxval must be greater than 0 and less than 65536" -def test_neg_ppm(): +def test_neg_ppm() -> None: # Storage.c accepted negative values for xsize, ysize. the # internal open_ppm function didn't check for sanity but it # has been removed. The default opener doesn't accept negative @@ -291,7 +340,7 @@ def test_neg_ppm(): pass -def test_mimetypes(tmp_path): +def test_mimetypes(tmp_path: Path) -> None: path = str(tmp_path / "temp.pgm") with open(path, "wb") as f: @@ -306,7 +355,7 @@ def test_mimetypes(tmp_path): @pytest.mark.parametrize("buffer", (True, False)) -def test_save_stdout(buffer): +def test_save_stdout(buffer: bool) -> None: old_stdout = sys.stdout if buffer: diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index d98f23356..484a1be8f 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -1,4 +1,5 @@ from __future__ import annotations + import warnings import pytest @@ -10,7 +11,7 @@ from .helper import assert_image_equal_tofile, assert_image_similar, hopper, is_ test_file = "Tests/images/hopper.psd" -def test_sanity(): +def test_sanity() -> None: with Image.open(test_file) as im: im.load() assert im.mode == "RGB" @@ -23,8 +24,8 @@ def test_sanity(): @pytest.mark.skipif(is_pypy(), reason="Requires CPython") -def test_unclosed_file(): - def open(): +def test_unclosed_file() -> None: + def open() -> None: im = Image.open(test_file) im.load() @@ -32,27 +33,27 @@ def test_unclosed_file(): open() -def test_closed_file(): +def test_closed_file() -> None: with warnings.catch_warnings(): im = Image.open(test_file) im.load() im.close() -def test_context_manager(): +def test_context_manager() -> None: with warnings.catch_warnings(): with Image.open(test_file) as im: im.load() -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): PsdImagePlugin.PsdImageFile(invalid_file) -def test_n_frames(): +def test_n_frames() -> None: with Image.open("Tests/images/hopper_merged.psd") as im: assert im.n_frames == 1 assert not im.is_animated @@ -63,7 +64,7 @@ def test_n_frames(): assert im.is_animated -def test_eoferror(): +def test_eoferror() -> None: with Image.open(test_file) as im: # PSD seek index starts at 1 rather than 0 n_frames = im.n_frames + 1 @@ -77,7 +78,7 @@ def test_eoferror(): im.seek(n_frames - 1) -def test_seek_tell(): +def test_seek_tell() -> None: with Image.open(test_file) as im: layer_number = im.tell() assert layer_number == 1 @@ -94,30 +95,35 @@ def test_seek_tell(): assert layer_number == 2 -def test_seek_eoferror(): +def test_seek_eoferror() -> None: with Image.open(test_file) as im: with pytest.raises(EOFError): im.seek(-1) -def test_open_after_exclusive_load(): +def test_open_after_exclusive_load() -> None: with Image.open(test_file) as im: im.load() im.seek(im.tell() + 1) im.load() -def test_rgba(): +def test_rgba() -> None: with Image.open("Tests/images/rgba.psd") as im: assert_image_equal_tofile(im, "Tests/images/imagedraw_square.png") -def test_layer_skip(): +def test_negative_top_left_layer() -> None: + with Image.open("Tests/images/negative_top_left_layer.psd") as im: + assert im.layers[0][2] == (-50, -50, 50, 50) + + +def test_layer_skip() -> None: with Image.open("Tests/images/five_channels.psd") as im: assert im.n_frames == 1 -def test_icc_profile(): +def test_icc_profile() -> None: with Image.open(test_file) as im: assert "icc_profile" in im.info @@ -125,12 +131,12 @@ def test_icc_profile(): assert len(icc_profile) == 3144 -def test_no_icc_profile(): +def test_no_icc_profile() -> None: with Image.open("Tests/images/hopper_merged.psd") as im: assert "icc_profile" not in im.info -def test_combined_larger_than_size(): +def test_combined_larger_than_size() -> None: # The combined size of the individual parts is larger than the # declared 'size' of the extra data field, resulting in a backwards seek. @@ -156,7 +162,7 @@ def test_combined_larger_than_size(): ("Tests/images/timeout-dedc7a4ebd856d79b4359bbcc79e8ef231ce38f6.psd", OSError), ], ) -def test_crashes(test_file, raises): +def test_crashes(test_file: str, raises) -> None: with open(test_file, "rb") as f: with pytest.raises(raises): with Image.open(f): diff --git a/Tests/test_file_qoi.py b/Tests/test_file_qoi.py index b7c945729..fd4b981ce 100644 --- a/Tests/test_file_qoi.py +++ b/Tests/test_file_qoi.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, QoiImagePlugin @@ -6,7 +7,7 @@ from PIL import Image, QoiImagePlugin from .helper import assert_image_equal_tofile -def test_sanity(): +def test_sanity() -> None: with Image.open("Tests/images/hopper.qoi") as im: assert im.mode == "RGB" assert im.size == (128, 128) @@ -22,7 +23,7 @@ def test_sanity(): assert_image_equal_tofile(im, "Tests/images/pil123rgba.png") -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): diff --git a/Tests/test_file_sgi.py b/Tests/test_file_sgi.py index 13698276b..e13a8019e 100644 --- a/Tests/test_file_sgi.py +++ b/Tests/test_file_sgi.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import Image, SgiImagePlugin @@ -11,7 +14,7 @@ from .helper import ( ) -def test_rgb(): +def test_rgb() -> None: # Created with ImageMagick then renamed: # convert hopper.ppm -compress None sgi:hopper.rgb test_file = "Tests/images/hopper.rgb" @@ -21,11 +24,11 @@ def test_rgb(): assert im.get_format_mimetype() == "image/rgb" -def test_rgb16(): +def test_rgb16() -> None: assert_image_equal_tofile(hopper(), "Tests/images/hopper16.rgb") -def test_l(): +def test_l() -> None: # Created with ImageMagick # convert hopper.ppm -monochrome -compress None sgi:hopper.bw test_file = "Tests/images/hopper.bw" @@ -35,7 +38,7 @@ def test_l(): assert im.get_format_mimetype() == "image/sgi" -def test_rgba(): +def test_rgba() -> None: # Created with ImageMagick: # convert transparent.png -compress None transparent.sgi test_file = "Tests/images/transparent.sgi" @@ -45,7 +48,7 @@ def test_rgba(): assert im.get_format_mimetype() == "image/sgi" -def test_rle(): +def test_rle() -> None: # Created with ImageMagick: # convert hopper.ppm hopper.sgi test_file = "Tests/images/hopper.sgi" @@ -54,22 +57,22 @@ def test_rle(): assert_image_equal_tofile(im, "Tests/images/hopper.rgb") -def test_rle16(): +def test_rle16() -> None: test_file = "Tests/images/tv16.sgi" with Image.open(test_file) as im: assert_image_equal_tofile(im, "Tests/images/tv.rgb") -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(ValueError): SgiImagePlugin.SgiImageFile(invalid_file) -def test_write(tmp_path): - def roundtrip(img): +def test_write(tmp_path: Path) -> None: + def roundtrip(img: Image.Image) -> None: out = str(tmp_path / "temp.sgi") img.save(out, format="sgi") assert_image_equal_tofile(img, out) @@ -88,7 +91,7 @@ def test_write(tmp_path): roundtrip(Image.new("L", (10, 1))) -def test_write16(tmp_path): +def test_write16(tmp_path: Path) -> None: test_file = "Tests/images/hopper16.rgb" with Image.open(test_file) as im: @@ -98,7 +101,7 @@ def test_write16(tmp_path): assert_image_equal_tofile(im, out) -def test_unsupported_mode(tmp_path): +def test_unsupported_mode(tmp_path: Path) -> None: im = hopper("LA") out = str(tmp_path / "temp.sgi") diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index f21098754..9b82a962a 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -1,18 +1,20 @@ from __future__ import annotations + import tempfile import warnings from io import BytesIO +from pathlib import Path import pytest from PIL import Image, ImageSequence, SpiderImagePlugin -from .helper import assert_image_equal_tofile, hopper, is_pypy +from .helper import assert_image_equal, hopper, is_pypy TEST_FILE = "Tests/images/hopper.spider" -def test_sanity(): +def test_sanity() -> None: with Image.open(TEST_FILE) as im: im.load() assert im.mode == "F" @@ -21,8 +23,8 @@ def test_sanity(): @pytest.mark.skipif(is_pypy(), reason="Requires CPython") -def test_unclosed_file(): - def open(): +def test_unclosed_file() -> None: + def open() -> None: im = Image.open(TEST_FILE) im.load() @@ -30,20 +32,20 @@ def test_unclosed_file(): open() -def test_closed_file(): +def test_closed_file() -> None: with warnings.catch_warnings(): im = Image.open(TEST_FILE) im.load() im.close() -def test_context_manager(): +def test_context_manager() -> None: with warnings.catch_warnings(): with Image.open(TEST_FILE) as im: im.load() -def test_save(tmp_path): +def test_save(tmp_path: Path) -> None: # Arrange temp = str(tmp_path / "temp.spider") im = hopper() @@ -58,7 +60,7 @@ def test_save(tmp_path): assert im2.format == "SPIDER" -def test_tempfile(): +def test_tempfile() -> None: # Arrange im = hopper() @@ -74,11 +76,11 @@ def test_tempfile(): assert reloaded.format == "SPIDER" -def test_is_spider_image(): +def test_is_spider_image() -> None: assert SpiderImagePlugin.isSpiderImage(TEST_FILE) -def test_tell(): +def test_tell() -> None: # Arrange with Image.open(TEST_FILE) as im: # Act @@ -88,13 +90,13 @@ def test_tell(): assert index == 0 -def test_n_frames(): +def test_n_frames() -> None: with Image.open(TEST_FILE) as im: assert im.n_frames == 1 assert not im.is_animated -def test_load_image_series(): +def test_load_image_series() -> None: # Arrange not_spider_file = "Tests/images/hopper.ppm" file_list = [TEST_FILE, not_spider_file, "path/not_found.ext"] @@ -108,7 +110,7 @@ def test_load_image_series(): assert img_list[0].size == (128, 128) -def test_load_image_series_no_input(): +def test_load_image_series_no_input() -> None: # Arrange file_list = None @@ -119,7 +121,7 @@ def test_load_image_series_no_input(): assert img_list is None -def test_is_int_not_a_number(): +def test_is_int_not_a_number() -> None: # Arrange not_a_number = "a" @@ -130,7 +132,7 @@ def test_is_int_not_a_number(): assert ret == 0 -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/invalid.spider" with pytest.raises(OSError): @@ -138,24 +140,25 @@ def test_invalid_file(): pass -def test_nonstack_file(): +def test_nonstack_file() -> None: with Image.open(TEST_FILE) as im: with pytest.raises(EOFError): im.seek(0) -def test_nonstack_dos(): +def test_nonstack_dos() -> None: with Image.open(TEST_FILE) as im: for i, frame in enumerate(ImageSequence.Iterator(im)): assert i <= 1, "Non-stack DOS file test failed" # for issue #4093 -def test_odd_size(): +def test_odd_size() -> None: data = BytesIO() width = 100 im = Image.new("F", (width, 64)) im.save(data, format="SPIDER") data.seek(0) - assert_image_equal_tofile(im, data) + with Image.open(data) as im2: + assert_image_equal(im, im2) diff --git a/Tests/test_file_sun.py b/Tests/test_file_sun.py index 874b37b52..6cfff8730 100644 --- a/Tests/test_file_sun.py +++ b/Tests/test_file_sun.py @@ -1,4 +1,5 @@ from __future__ import annotations + import os import pytest @@ -10,7 +11,7 @@ from .helper import assert_image_equal_tofile, assert_image_similar, hopper EXTRA_DIR = "Tests/images/sunraster" -def test_sanity(): +def test_sanity() -> None: # Arrange # Created with ImageMagick: convert hopper.jpg hopper.ras test_file = "Tests/images/hopper.ras" @@ -27,7 +28,7 @@ def test_sanity(): SunImagePlugin.SunImageFile(invalid_file) -def test_im1(): +def test_im1() -> None: with Image.open("Tests/images/sunraster.im1") as im: assert_image_equal_tofile(im, "Tests/images/sunraster.im1.png") @@ -35,7 +36,7 @@ def test_im1(): @pytest.mark.skipif( not os.path.exists(EXTRA_DIR), reason="Extra image files not installed" ) -def test_others(): +def test_others() -> None: files = ( os.path.join(EXTRA_DIR, f) for f in os.listdir(EXTRA_DIR) diff --git a/Tests/test_file_tar.py b/Tests/test_file_tar.py index 4470823cd..6217ebedd 100644 --- a/Tests/test_file_tar.py +++ b/Tests/test_file_tar.py @@ -1,4 +1,5 @@ from __future__ import annotations + import warnings import pytest @@ -18,7 +19,7 @@ TEST_TAR_FILE = "Tests/images/hopper.tar" ("jpg", "hopper.jpg", "JPEG"), ), ) -def test_sanity(codec, test_path, format): +def test_sanity(codec: str, test_path: str, format: str) -> None: if features.check(codec): with TarIO.TarIO(TEST_TAR_FILE, test_path) as tar: with Image.open(tar) as im: @@ -29,18 +30,18 @@ def test_sanity(codec, test_path, format): @pytest.mark.skipif(is_pypy(), reason="Requires CPython") -def test_unclosed_file(): +def test_unclosed_file() -> None: with pytest.warns(ResourceWarning): TarIO.TarIO(TEST_TAR_FILE, "hopper.jpg") -def test_close(): +def test_close() -> None: with warnings.catch_warnings(): tar = TarIO.TarIO(TEST_TAR_FILE, "hopper.jpg") tar.close() -def test_contextmanager(): +def test_contextmanager() -> None: with warnings.catch_warnings(): with TarIO.TarIO(TEST_TAR_FILE, "hopper.jpg"): pass diff --git a/Tests/test_file_tga.py b/Tests/test_file_tga.py index d0f228573..ff6dab00d 100644 --- a/Tests/test_file_tga.py +++ b/Tests/test_file_tga.py @@ -1,11 +1,13 @@ from __future__ import annotations + import os from glob import glob from itertools import product +from pathlib import Path import pytest -from PIL import Image +from PIL import Image, UnidentifiedImageError from .helper import assert_image_equal, assert_image_equal_tofile, hopper @@ -20,8 +22,8 @@ _ORIGIN_TO_ORIENTATION = {"tl": 1, "bl": -1} @pytest.mark.parametrize("mode", _MODES) -def test_sanity(mode, tmp_path): - def roundtrip(original_im): +def test_sanity(mode: str, tmp_path: Path) -> None: + def roundtrip(original_im: Image.Image) -> None: out = str(tmp_path / "temp.tga") original_im.save(out, rle=rle) @@ -63,7 +65,12 @@ def test_sanity(mode, tmp_path): roundtrip(original_im) -def test_palette_depth_16(tmp_path): +def test_palette_depth_8(tmp_path: Path) -> None: + with pytest.raises(UnidentifiedImageError): + Image.open("Tests/images/p_8.tga") + + +def test_palette_depth_16(tmp_path: Path) -> None: with Image.open("Tests/images/p_16.tga") as im: assert_image_equal_tofile(im.convert("RGB"), "Tests/images/p_16.png") @@ -73,7 +80,7 @@ def test_palette_depth_16(tmp_path): assert_image_equal_tofile(reloaded.convert("RGB"), "Tests/images/p_16.png") -def test_id_field(): +def test_id_field() -> None: # tga file with id field test_file = "Tests/images/tga_id_field.tga" @@ -83,7 +90,7 @@ def test_id_field(): assert im.size == (100, 100) -def test_id_field_rle(): +def test_id_field_rle() -> None: # tga file with id field test_file = "Tests/images/rgb32rle.tga" @@ -93,7 +100,7 @@ def test_id_field_rle(): assert im.size == (199, 199) -def test_cross_scan_line(): +def test_cross_scan_line() -> None: with Image.open("Tests/images/cross_scan_line.tga") as im: assert_image_equal_tofile(im, "Tests/images/cross_scan_line.png") @@ -102,7 +109,7 @@ def test_cross_scan_line(): im.load() -def test_save(tmp_path): +def test_save(tmp_path: Path) -> None: test_file = "Tests/images/tga_id_field.tga" with Image.open(test_file) as im: out = str(tmp_path / "temp.tga") @@ -119,7 +126,7 @@ def test_save(tmp_path): assert test_im.size == (100, 100) -def test_small_palette(tmp_path): +def test_small_palette(tmp_path: Path) -> None: im = Image.new("P", (1, 1)) colors = [0, 0, 0] im.putpalette(colors) @@ -131,7 +138,12 @@ def test_small_palette(tmp_path): assert reloaded.getpalette() == colors -def test_save_wrong_mode(tmp_path): +def test_missing_palette() -> None: + with Image.open("Tests/images/dilation4.lut") as im: + assert im.mode == "L" + + +def test_save_wrong_mode(tmp_path: Path) -> None: im = hopper("PA") out = str(tmp_path / "temp.tga") @@ -139,7 +151,7 @@ def test_save_wrong_mode(tmp_path): im.save(out) -def test_save_mapdepth(): +def test_save_mapdepth() -> None: # This image has been manually hexedited from 200x32_p_bl_raw.tga # to include an origin test_file = "Tests/images/200x32_p_bl_raw_origin.tga" @@ -147,7 +159,7 @@ def test_save_mapdepth(): assert_image_equal_tofile(im, "Tests/images/tga/common/200x32_p.png") -def test_save_id_section(tmp_path): +def test_save_id_section(tmp_path: Path) -> None: test_file = "Tests/images/rgb32rle.tga" with Image.open(test_file) as im: out = str(tmp_path / "temp.tga") @@ -178,7 +190,7 @@ def test_save_id_section(tmp_path): assert "id_section" not in test_im.info -def test_save_orientation(tmp_path): +def test_save_orientation(tmp_path: Path) -> None: test_file = "Tests/images/rgb32rle.tga" out = str(tmp_path / "temp.tga") with Image.open(test_file) as im: @@ -189,7 +201,7 @@ def test_save_orientation(tmp_path): assert test_im.info["orientation"] == 1 -def test_horizontal_orientations(): +def test_horizontal_orientations() -> None: # These images have been manually hexedited to have the relevant orientations with Image.open("Tests/images/rgb32rle_top_right.tga") as im: assert im.load()[90, 90][:3] == (0, 0, 0) @@ -198,7 +210,7 @@ def test_horizontal_orientations(): assert im.load()[90, 90][:3] == (0, 255, 0) -def test_save_rle(tmp_path): +def test_save_rle(tmp_path: Path) -> None: test_file = "Tests/images/rgb32rle.tga" with Image.open(test_file) as im: assert im.info["compression"] == "tga_rle" @@ -231,7 +243,7 @@ def test_save_rle(tmp_path): assert test_im.info["compression"] == "tga_rle" -def test_save_l_transparency(tmp_path): +def test_save_l_transparency(tmp_path: Path) -> None: # There are 559 transparent pixels in la.tga. num_transparent = 559 diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 0851796d0..21d52462e 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -1,7 +1,11 @@ from __future__ import annotations + import os import warnings from io import BytesIO +from pathlib import Path +from types import ModuleType +from typing import Generator import pytest @@ -18,6 +22,7 @@ from .helper import ( is_win32, ) +ElementTree: ModuleType | None try: from defusedxml import ElementTree except ImportError: @@ -25,7 +30,7 @@ except ImportError: class TestFileTiff: - def test_sanity(self, tmp_path): + def test_sanity(self, tmp_path: Path) -> None: filename = str(tmp_path / "temp.tif") hopper("RGB").save(filename) @@ -57,21 +62,21 @@ class TestFileTiff: pass @pytest.mark.skipif(is_pypy(), reason="Requires CPython") - def test_unclosed_file(self): - def open(): + def test_unclosed_file(self) -> None: + def open() -> None: im = Image.open("Tests/images/multipage.tiff") im.load() with pytest.warns(ResourceWarning): open() - def test_closed_file(self): + def test_closed_file(self) -> None: with warnings.catch_warnings(): im = Image.open("Tests/images/multipage.tiff") im.load() im.close() - def test_seek_after_close(self): + def test_seek_after_close(self) -> None: im = Image.open("Tests/images/multipage.tiff") im.close() @@ -80,12 +85,12 @@ class TestFileTiff: with pytest.raises(ValueError): im.seek(1) - def test_context_manager(self): + def test_context_manager(self) -> None: with warnings.catch_warnings(): with Image.open("Tests/images/multipage.tiff") as im: im.load() - def test_mac_tiff(self): + def test_mac_tiff(self) -> None: # Read RGBa images from macOS [@PIL136] filename = "Tests/images/pil136.tiff" @@ -97,7 +102,7 @@ class TestFileTiff: assert_image_similar_tofile(im, "Tests/images/pil136.png", 1) - def test_bigtiff(self, tmp_path): + def test_bigtiff(self, tmp_path: Path) -> None: with Image.open("Tests/images/hopper_bigtiff.tif") as im: assert_image_equal_tofile(im, "Tests/images/hopper.tif") @@ -108,13 +113,13 @@ class TestFileTiff: 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): + def test_set_legacy_api(self) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() with pytest.raises(Exception) as e: ifd.legacy_api = None assert str(e.value) == "Not allowing setting of legacy api" - def test_xyres_tiff(self): + def test_xyres_tiff(self) -> None: filename = "Tests/images/pil168.tif" with Image.open(filename) as im: # legacy api @@ -127,7 +132,7 @@ class TestFileTiff: assert im.info["dpi"] == (72.0, 72.0) - def test_xyres_fallback_tiff(self): + def test_xyres_fallback_tiff(self) -> None: filename = "Tests/images/compression.tif" with Image.open(filename) as im: # v2 api @@ -141,7 +146,7 @@ class TestFileTiff: # Fallback "inch". assert im.info["dpi"] == (100.0, 100.0) - def test_int_resolution(self): + def test_int_resolution(self) -> None: filename = "Tests/images/pil168.tif" with Image.open(filename) as im: # Try to read a file where X,Y_RESOLUTION are ints @@ -154,14 +159,14 @@ class TestFileTiff: "resolution_unit, dpi", [(None, 72.8), (2, 72.8), (3, 184.912)], ) - def test_load_float_dpi(self, resolution_unit, dpi): + def test_load_float_dpi(self, resolution_unit: int | None, dpi: float) -> None: with Image.open( "Tests/images/hopper_float_dpi_" + str(resolution_unit) + ".tif" ) as im: assert im.tag_v2.get(RESOLUTION_UNIT) == resolution_unit assert im.info["dpi"] == (dpi, dpi) - def test_save_float_dpi(self, tmp_path): + def test_save_float_dpi(self, tmp_path: Path) -> None: outfile = str(tmp_path / "temp.tif") with Image.open("Tests/images/hopper.tif") as im: dpi = (72.2, 72.2) @@ -170,7 +175,7 @@ class TestFileTiff: with Image.open(outfile) as reloaded: assert reloaded.info["dpi"] == dpi - def test_save_setting_missing_resolution(self): + def test_save_setting_missing_resolution(self) -> None: b = BytesIO() with Image.open("Tests/images/10ct_32bit_128.tiff") as im: im.save(b, format="tiff", resolution=123.45) @@ -178,7 +183,7 @@ class TestFileTiff: assert im.tag_v2[X_RESOLUTION] == 123.45 assert im.tag_v2[Y_RESOLUTION] == 123.45 - def test_invalid_file(self): + def test_invalid_file(self) -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): @@ -189,30 +194,30 @@ class TestFileTiff: TiffImagePlugin.TiffImageFile(invalid_file) TiffImagePlugin.PREFIXES.pop() - def test_bad_exif(self): + def test_bad_exif(self) -> None: with Image.open("Tests/images/hopper_bad_exif.jpg") as i: # Should not raise struct.error. with pytest.warns(UserWarning): i._getexif() - def test_save_rgba(self, tmp_path): + def test_save_rgba(self, tmp_path: Path) -> None: im = hopper("RGBA") outfile = str(tmp_path / "temp.tif") im.save(outfile) - def test_save_unsupported_mode(self, tmp_path): + def test_save_unsupported_mode(self, tmp_path: Path) -> None: im = hopper("HSV") outfile = str(tmp_path / "temp.tif") with pytest.raises(OSError): im.save(outfile) - def test_8bit_s(self): + def test_8bit_s(self) -> None: 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): + def test_little_endian(self) -> None: with Image.open("Tests/images/16bit.cropped.tif") as im: assert im.getpixel((0, 0)) == 480 assert im.mode == "I;16" @@ -222,7 +227,7 @@ class TestFileTiff: assert b[0] == ord(b"\xe0") assert b[1] == ord(b"\x01") - def test_big_endian(self): + def test_big_endian(self) -> None: with Image.open("Tests/images/16bit.MM.cropped.tif") as im: assert im.getpixel((0, 0)) == 480 assert im.mode == "I;16B" @@ -232,7 +237,7 @@ class TestFileTiff: assert b[0] == ord(b"\x01") assert b[1] == ord(b"\xe0") - def test_16bit_r(self): + def test_16bit_r(self) -> None: with Image.open("Tests/images/16bit.r.tif") as im: assert im.getpixel((0, 0)) == 480 assert im.mode == "I;16" @@ -241,14 +246,14 @@ class TestFileTiff: assert b[0] == ord(b"\xe0") assert b[1] == ord(b"\x01") - def test_16bit_s(self): + def test_16bit_s(self) -> None: with Image.open("Tests/images/16bit.s.tif") as im: im.load() assert im.mode == "I" assert im.getpixel((0, 0)) == 32767 assert im.getpixel((0, 1)) == 0 - def test_12bit_rawmode(self): + def test_12bit_rawmode(self) -> None: """Are we generating the same interpretation of the image as Imagemagick is?""" @@ -261,7 +266,7 @@ class TestFileTiff: assert_image_equal_tofile(im, "Tests/images/12in16bit.tif") - def test_32bit_float(self): + def test_32bit_float(self) -> None: # Issue 614, specific 32-bit float format path = "Tests/images/10ct_32bit_128.tiff" with Image.open(path) as im: @@ -270,7 +275,7 @@ class TestFileTiff: assert im.getpixel((0, 0)) == -0.4526388943195343 assert im.getextrema() == (-3.140936851501465, 3.140684127807617) - def test_unknown_pixel_mode(self): + def test_unknown_pixel_mode(self) -> None: with pytest.raises(OSError): with Image.open("Tests/images/hopper_unknown_pixel_mode.tif"): pass @@ -282,12 +287,12 @@ class TestFileTiff: ("Tests/images/multipage.tiff", 3), ), ) - def test_n_frames(self, path, n_frames): + def test_n_frames(self, path: str, n_frames: int) -> None: with Image.open(path) as im: assert im.n_frames == n_frames assert im.is_animated == (n_frames != 1) - def test_eoferror(self): + def test_eoferror(self) -> None: with Image.open("Tests/images/multipage-lastframe.tif") as im: n_frames = im.n_frames @@ -299,7 +304,7 @@ class TestFileTiff: # Test that seeking to the last frame does not raise an error im.seek(n_frames - 1) - def test_multipage(self): + def test_multipage(self) -> None: # issue #862 with Image.open("Tests/images/multipage.tiff") as im: # file is a multipage tiff: 10x10 green, 10x10 red, 20x20 blue @@ -323,13 +328,13 @@ class TestFileTiff: assert im.size == (20, 20) assert im.convert("RGB").getpixel((0, 0)) == (0, 0, 255) - def test_multipage_last_frame(self): + def test_multipage_last_frame(self) -> None: with Image.open("Tests/images/multipage-lastframe.tif") as im: im.load() assert im.size == (20, 20) assert im.convert("RGB").getpixel((0, 0)) == (0, 0, 255) - def test_frame_order(self): + def test_frame_order(self) -> None: # A frame can't progress to itself after reading with Image.open("Tests/images/multipage_single_frame_loop.tiff") as im: assert im.n_frames == 1 @@ -342,7 +347,7 @@ class TestFileTiff: with Image.open("Tests/images/multipage_out_of_order.tiff") as im: assert im.n_frames == 3 - def test___str__(self): + def test___str__(self) -> None: filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: # Act @@ -351,7 +356,7 @@ class TestFileTiff: # Assert assert isinstance(ret, str) - def test_dict(self): + def test_dict(self) -> None: # Arrange filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: @@ -391,7 +396,7 @@ class TestFileTiff: } assert dict(im.tag) == legacy_tags - def test__delitem__(self): + def test__delitem__(self) -> None: filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: len_before = len(dict(im.ifd)) @@ -400,36 +405,36 @@ class TestFileTiff: assert len_before == len_after + 1 @pytest.mark.parametrize("legacy_api", (False, True)) - def test_load_byte(self, legacy_api): + def test_load_byte(self, legacy_api: bool) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() data = b"abc" ret = ifd.load_byte(data, legacy_api) assert ret == b"abc" - def test_load_string(self): + def test_load_string(self) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() data = b"abc\0" ret = ifd.load_string(data, False) assert ret == "abc" - def test_load_float(self): + def test_load_float(self) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() data = b"abcdabcd" ret = ifd.load_float(data, False) assert ret == (1.6777999408082104e22, 1.6777999408082104e22) - def test_load_double(self): + def test_load_double(self) -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() data = b"abcdefghabcdefgh" ret = ifd.load_double(data, False) assert ret == (8.540883223036124e194, 8.540883223036124e194) - def test_ifd_tag_type(self): + def test_ifd_tag_type(self) -> None: with Image.open("Tests/images/ifd_tag_type.tiff") as im: assert 0x8825 in im.tag_v2 - def test_exif(self, tmp_path): - def check_exif(exif): + def test_exif(self, tmp_path: Path) -> None: + def check_exif(exif: Image.Exif) -> None: assert sorted(exif.keys()) == [ 256, 257, @@ -480,19 +485,19 @@ class TestFileTiff: exif = im.getexif() check_exif(exif) - def test_modify_exif(self, tmp_path): + def test_modify_exif(self, tmp_path: Path) -> None: outfile = str(tmp_path / "temp.tif") with Image.open("Tests/images/ifd_tag_type.tiff") as im: exif = im.getexif() - exif[256] = 100 + exif[264] = 100 im.save(outfile, exif=exif) with Image.open(outfile) as im: exif = im.getexif() - assert exif[256] == 100 + assert exif[264] == 100 - def test_reload_exif_after_seek(self): + def test_reload_exif_after_seek(self) -> None: with Image.open("Tests/images/multipage.tiff") as im: exif = im.getexif() del exif[256] @@ -500,7 +505,7 @@ class TestFileTiff: assert 256 in exif - def test_exif_frames(self): + def test_exif_frames(self) -> None: # Test that EXIF data can change across frames with Image.open("Tests/images/g4-multi.tiff") as im: assert im.getexif()[273] == (328, 815) @@ -509,7 +514,7 @@ class TestFileTiff: assert im.getexif()[273] == (1408, 1907) @pytest.mark.parametrize("mode", ("1", "L")) - def test_photometric(self, mode, tmp_path): + def test_photometric(self, mode: str, tmp_path: Path) -> None: filename = str(tmp_path / "temp.tif") im = hopper(mode) im.save(filename, tiffinfo={262: 0}) @@ -517,13 +522,13 @@ class TestFileTiff: assert reloaded.tag_v2[262] == 0 assert_image_equal(im, reloaded) - def test_seek(self): + def test_seek(self) -> None: filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: im.seek(0) assert im.tell() == 0 - def test_seek_eof(self): + def test_seek_eof(self) -> None: filename = "Tests/images/pil136.tiff" with Image.open(filename) as im: assert im.tell() == 0 @@ -532,21 +537,21 @@ class TestFileTiff: with pytest.raises(EOFError): im.seek(1) - def test__limit_rational_int(self): + def test__limit_rational_int(self) -> None: from PIL.TiffImagePlugin import _limit_rational value = 34 ret = _limit_rational(value, 65536) assert ret == (34, 1) - def test__limit_rational_float(self): + def test__limit_rational_float(self) -> None: from PIL.TiffImagePlugin import _limit_rational value = 22.3 ret = _limit_rational(value, 65536) assert ret == (223, 10) - def test_4bit(self): + def test_4bit(self) -> None: test_file = "Tests/images/hopper_gray_4bpp.tif" original = hopper("L") with Image.open(test_file) as im: @@ -554,7 +559,7 @@ class TestFileTiff: assert im.mode == "L" assert_image_similar(im, original, 7.3) - def test_gray_semibyte_per_pixel(self): + def test_gray_semibyte_per_pixel(self) -> None: test_files = ( ( 24.8, # epsilon @@ -587,7 +592,7 @@ class TestFileTiff: assert im2.mode == "L" assert_image_equal(im, im2) - def test_with_underscores(self, tmp_path): + def test_with_underscores(self, tmp_path: Path) -> None: kwargs = {"resolution_unit": "inch", "x_resolution": 72, "y_resolution": 36} filename = str(tmp_path / "temp.tif") hopper("RGB").save(filename, **kwargs) @@ -600,7 +605,7 @@ class TestFileTiff: assert im.tag_v2[X_RESOLUTION] == 72 assert im.tag_v2[Y_RESOLUTION] == 36 - def test_roundtrip_tiff_uint16(self, tmp_path): + def test_roundtrip_tiff_uint16(self, tmp_path: Path) -> None: # Test an image of all '0' values pixel_value = 0x1234 infile = "Tests/images/uint16_1_4660.tif" @@ -612,25 +617,34 @@ class TestFileTiff: assert_image_equal_tofile(im, tmpfile) - def test_strip_raw(self): + def test_rowsperstrip(self, tmp_path: Path) -> None: + outfile = str(tmp_path / "temp.tif") + im = hopper() + im.save(outfile, tiffinfo={278: 256}) + + with Image.open(outfile) as im: + assert isinstance(im, TiffImagePlugin.TiffImageFile) + assert im.tag_v2[278] == 256 + + def test_strip_raw(self) -> None: infile = "Tests/images/tiff_strip_raw.tif" with Image.open(infile) as im: assert_image_equal_tofile(im, "Tests/images/tiff_adobe_deflate.png") - def test_strip_planar_raw(self): + def test_strip_planar_raw(self) -> None: # gdal_translate -of GTiff -co INTERLEAVE=BAND \ # tiff_strip_raw.tif tiff_strip_planar_raw.tiff infile = "Tests/images/tiff_strip_planar_raw.tif" with Image.open(infile) as im: assert_image_equal_tofile(im, "Tests/images/tiff_adobe_deflate.png") - def test_strip_planar_raw_with_overviews(self): + def test_strip_planar_raw_with_overviews(self) -> None: # gdaladdo tiff_strip_planar_raw2.tif 2 4 8 16 infile = "Tests/images/tiff_strip_planar_raw_with_overviews.tif" with Image.open(infile) as im: assert_image_equal_tofile(im, "Tests/images/tiff_adobe_deflate.png") - def test_tiled_planar_raw(self): + def test_tiled_planar_raw(self) -> None: # gdal_translate -of GTiff -co TILED=YES -co BLOCKXSIZE=32 \ # -co BLOCKYSIZE=32 -co INTERLEAVE=BAND \ # tiff_tiled_raw.tif tiff_tiled_planar_raw.tiff @@ -638,7 +652,7 @@ class TestFileTiff: with Image.open(infile) as im: assert_image_equal_tofile(im, "Tests/images/tiff_adobe_deflate.png") - def test_planar_configuration_save(self, tmp_path): + def test_planar_configuration_save(self, tmp_path: Path) -> None: infile = "Tests/images/tiff_tiled_planar_raw.tif" with Image.open(infile) as im: assert im._planar_configuration == 2 @@ -650,7 +664,7 @@ class TestFileTiff: assert_image_equal_tofile(reloaded, infile) @pytest.mark.parametrize("mode", ("P", "PA")) - def test_palette(self, mode, tmp_path): + def test_palette(self, mode: str, tmp_path: Path) -> None: outfile = str(tmp_path / "temp.tif") im = hopper(mode) @@ -659,7 +673,7 @@ class TestFileTiff: with Image.open(outfile) as reloaded: assert_image_equal(im.convert("RGB"), reloaded.convert("RGB")) - def test_tiff_save_all(self): + def test_tiff_save_all(self) -> None: mp = BytesIO() with Image.open("Tests/images/multipage.tiff") as im: im.save(mp, format="tiff", save_all=True) @@ -679,7 +693,7 @@ class TestFileTiff: assert reread.n_frames == 3 # Test appending using a generator - def im_generator(ims): + def im_generator(ims: list[Image.Image]) -> Generator[Image.Image, None, None]: yield from ims mp = BytesIO() @@ -689,7 +703,7 @@ class TestFileTiff: with Image.open(mp) as reread: assert reread.n_frames == 3 - def test_saving_icc_profile(self, tmp_path): + def test_saving_icc_profile(self, tmp_path: Path) -> None: # Tests saving TIFF with icc_profile set. # At the time of writing this will only work for non-compressed tiffs # as libtiff does not support embedded ICC profiles, @@ -703,7 +717,7 @@ class TestFileTiff: with Image.open(tmpfile) as reloaded: assert b"Dummy value" == reloaded.info["icc_profile"] - def test_save_icc_profile(self, tmp_path): + def test_save_icc_profile(self, tmp_path: Path) -> None: im = hopper() assert "icc_profile" not in im.info @@ -714,14 +728,14 @@ class TestFileTiff: with Image.open(outfile) as reloaded: assert reloaded.info["icc_profile"] == icc_profile - def test_save_bmp_compression(self, tmp_path): + def test_save_bmp_compression(self, tmp_path: Path) -> None: with Image.open("Tests/images/hopper.bmp") as im: assert im.info["compression"] == 0 outfile = str(tmp_path / "temp.tif") im.save(outfile) - def test_discard_icc_profile(self, tmp_path): + def test_discard_icc_profile(self, tmp_path: Path) -> None: outfile = str(tmp_path / "temp.tif") with Image.open("Tests/images/icc_profile.png") as im: @@ -732,7 +746,7 @@ class TestFileTiff: with Image.open(outfile) as reloaded: assert "icc_profile" not in reloaded.info - def test_getxmp(self): + def test_getxmp(self) -> None: with Image.open("Tests/images/lab.tif") as im: if ElementTree is None: with pytest.warns( @@ -747,7 +761,7 @@ class TestFileTiff: assert description[0]["format"] == "image/tiff" assert description[3]["BitsPerSample"]["Seq"]["li"] == ["8", "8", "8"] - def test_get_photoshop_blocks(self): + def test_get_photoshop_blocks(self) -> None: with Image.open("Tests/images/lab.tif") as im: assert list(im.get_photoshop_blocks().keys()) == [ 1061, @@ -773,7 +787,28 @@ class TestFileTiff: 4001, ] - def test_close_on_load_exclusive(self, tmp_path): + def test_tiff_chunks(self, tmp_path: Path) -> None: + tmpfile = str(tmp_path / "temp.tif") + + im = hopper() + with open(tmpfile, "wb") as fp: + for y in range(0, 128, 32): + chunk = im.crop((0, y, 128, y + 32)) + if y == 0: + chunk.save( + fp, + "TIFF", + tiffinfo={ + TiffImagePlugin.IMAGEWIDTH: 128, + TiffImagePlugin.IMAGELENGTH: 128, + }, + ) + else: + fp.write(chunk.tobytes()) + + assert_image_equal_tofile(im, tmpfile) + + def test_close_on_load_exclusive(self, tmp_path: Path) -> None: # similar to test_fd_leak, but runs on unixlike os tmpfile = str(tmp_path / "temp.tif") @@ -786,7 +821,7 @@ class TestFileTiff: im.load() assert fp.closed - def test_close_on_load_nonexclusive(self, tmp_path): + def test_close_on_load_nonexclusive(self, tmp_path: Path) -> None: tmpfile = str(tmp_path / "temp.tif") with Image.open("Tests/images/uint16_1_4660.tif") as im: @@ -808,7 +843,7 @@ class TestFileTiff: not os.path.exists("Tests/images/string_dimension.tiff"), reason="Extra image files not installed", ) - def test_string_dimension(self): + def test_string_dimension(self) -> None: # Assert that an error is raised if one of the dimensions is a string with Image.open("Tests/images/string_dimension.tiff") as im: with pytest.raises(OSError): @@ -816,7 +851,7 @@ class TestFileTiff: @pytest.mark.timeout(6) @pytest.mark.filterwarnings("ignore:Truncated File Read") - def test_timeout(self): + def test_timeout(self) -> None: with Image.open("Tests/images/timeout-6646305047838720") as im: ImageFile.LOAD_TRUNCATED_IMAGES = True im.load() @@ -829,7 +864,7 @@ class TestFileTiff: ], ) @pytest.mark.timeout(2) - def test_oom(self, test_file): + def test_oom(self, test_file: str) -> None: with pytest.raises(UnidentifiedImageError): with pytest.warns(UserWarning): with Image.open(test_file): @@ -838,7 +873,7 @@ class TestFileTiff: @pytest.mark.skipif(not is_win32(), reason="Windows only") class TestFileTiffW32: - def test_fd_leak(self, tmp_path): + def test_fd_leak(self, tmp_path: Path) -> None: tmpfile = str(tmp_path / "temp.tif") # this is an mmaped file. diff --git a/Tests/test_file_tiff_metadata.py b/Tests/test_file_tiff_metadata.py index edd57e6b5..8b816aa4f 100644 --- a/Tests/test_file_tiff_metadata.py +++ b/Tests/test_file_tiff_metadata.py @@ -1,6 +1,8 @@ from __future__ import annotations + import io import struct +from pathlib import Path import pytest @@ -12,7 +14,7 @@ from .helper import assert_deep_equal, hopper TAG_IDS = {info.name: info.value for info in TiffTags.TAGS_V2.values()} -def test_rt_metadata(tmp_path): +def test_rt_metadata(tmp_path: Path) -> None: """Test writing arbitrary metadata into the tiff image directory Use case is ImageJ private tags, one numeric, one arbitrary data. https://github.com/python-pillow/Pillow/issues/291 @@ -78,7 +80,7 @@ def test_rt_metadata(tmp_path): assert loaded.tag_v2[ImageJMetaDataByteCounts] == (8, len(bin_data) - 8) -def test_read_metadata(): +def test_read_metadata() -> None: with Image.open("Tests/images/hopper_g4.tif") as img: assert { "YResolution": IFDRational(4294967295, 113653537), @@ -119,10 +121,11 @@ def test_read_metadata(): } == img.tag.named() -def test_write_metadata(tmp_path): +def test_write_metadata(tmp_path: Path) -> None: """Test metadata writing through the python code""" with Image.open("Tests/images/hopper.tif") as img: f = str(tmp_path / "temp.tiff") + del img.tag[278] img.save(f, tiffinfo=img.tag) original = img.tag_v2.named() @@ -155,13 +158,15 @@ def test_write_metadata(tmp_path): assert value == reloaded[tag], f"{tag} didn't roundtrip" -def test_change_stripbytecounts_tag_type(tmp_path): +def test_change_stripbytecounts_tag_type(tmp_path: Path) -> None: out = str(tmp_path / "temp.tiff") with Image.open("Tests/images/hopper.tif") as im: info = im.tag_v2 + del info[278] # Resize the image so that STRIPBYTECOUNTS will be larger than a SHORT im = im.resize((500, 500)) + info[TiffImagePlugin.IMAGEWIDTH] = im.width # STRIPBYTECOUNTS can be a SHORT or a LONG info.tagtype[TiffImagePlugin.STRIPBYTECOUNTS] = TiffTags.SHORT @@ -172,19 +177,21 @@ def test_change_stripbytecounts_tag_type(tmp_path): assert reloaded.tag_v2.tagtype[TiffImagePlugin.STRIPBYTECOUNTS] == TiffTags.LONG -def test_no_duplicate_50741_tag(): +def test_no_duplicate_50741_tag() -> None: assert TAG_IDS["MakerNoteSafety"] == 50741 assert TAG_IDS["BestQualityScale"] == 50780 -def test_iptc(tmp_path): +def test_iptc(tmp_path: Path) -> None: out = str(tmp_path / "temp.tiff") with Image.open("Tests/images/hopper.Lab.tif") as im: im.save(out) @pytest.mark.parametrize("value, expected", ((b"test", "test"), (1, "1"))) -def test_writing_other_types_to_ascii(value, expected, tmp_path): +def test_writing_other_types_to_ascii( + value: bytes | int, expected: str, tmp_path: Path +) -> None: info = TiffImagePlugin.ImageFileDirectory_v2() tag = TiffTags.TAGS_V2[271] @@ -201,7 +208,7 @@ def test_writing_other_types_to_ascii(value, expected, tmp_path): @pytest.mark.parametrize("value", (1, IFDRational(1))) -def test_writing_other_types_to_bytes(value, tmp_path): +def test_writing_other_types_to_bytes(value: int | IFDRational, tmp_path: Path) -> None: im = hopper() info = TiffImagePlugin.ImageFileDirectory_v2() @@ -217,14 +224,17 @@ def test_writing_other_types_to_bytes(value, tmp_path): assert reloaded.tag_v2[700] == b"\x01" -def test_writing_other_types_to_undefined(tmp_path): +@pytest.mark.parametrize("value", (1, IFDRational(1))) +def test_writing_other_types_to_undefined( + value: int | IFDRational, tmp_path: Path +) -> None: im = hopper() info = TiffImagePlugin.ImageFileDirectory_v2() tag = TiffTags.TAGS_V2[33723] assert tag.type == TiffTags.UNDEFINED - info[33723] = 1 + info[33723] = value out = str(tmp_path / "temp.tiff") im.save(out, tiffinfo=info) @@ -233,7 +243,7 @@ def test_writing_other_types_to_undefined(tmp_path): assert reloaded.tag_v2[33723] == b"1" -def test_undefined_zero(tmp_path): +def test_undefined_zero(tmp_path: Path) -> None: # Check that the tag has not been changed since this test was created tag = TiffTags.TAGS_V2[45059] assert tag.type == TiffTags.UNDEFINED @@ -248,7 +258,7 @@ def test_undefined_zero(tmp_path): assert info[45059] == original -def test_empty_metadata(): +def test_empty_metadata() -> None: f = io.BytesIO(b"II*\x00\x08\x00\x00\x00") head = f.read(8) info = TiffImagePlugin.ImageFileDirectory(head) @@ -257,7 +267,7 @@ def test_empty_metadata(): info.load(f) -def test_iccprofile(tmp_path): +def test_iccprofile(tmp_path: Path) -> None: # https://github.com/python-pillow/Pillow/issues/1462 out = str(tmp_path / "temp.tiff") with Image.open("Tests/images/hopper.iccprofile.tif") as im: @@ -268,7 +278,7 @@ def test_iccprofile(tmp_path): assert im.info["icc_profile"] == reloaded.info["icc_profile"] -def test_iccprofile_binary(): +def test_iccprofile_binary() -> None: # https://github.com/python-pillow/Pillow/issues/1526 # We should be able to load this, # but probably won't be able to save it. @@ -278,19 +288,19 @@ def test_iccprofile_binary(): assert im.info["icc_profile"] -def test_iccprofile_save_png(tmp_path): +def test_iccprofile_save_png(tmp_path: Path) -> None: with Image.open("Tests/images/hopper.iccprofile.tif") as im: outfile = str(tmp_path / "temp.png") im.save(outfile) -def test_iccprofile_binary_save_png(tmp_path): +def test_iccprofile_binary_save_png(tmp_path: Path) -> None: with Image.open("Tests/images/hopper.iccprofile_binary.tif") as im: outfile = str(tmp_path / "temp.png") im.save(outfile) -def test_exif_div_zero(tmp_path): +def test_exif_div_zero(tmp_path: Path) -> None: im = hopper() info = TiffImagePlugin.ImageFileDirectory_v2() info[41988] = TiffImagePlugin.IFDRational(0, 0) @@ -303,7 +313,7 @@ def test_exif_div_zero(tmp_path): assert 0 == reloaded.tag_v2[41988].denominator -def test_ifd_unsigned_rational(tmp_path): +def test_ifd_unsigned_rational(tmp_path: Path) -> None: im = hopper() info = TiffImagePlugin.ImageFileDirectory_v2() @@ -334,7 +344,7 @@ def test_ifd_unsigned_rational(tmp_path): assert 1 == reloaded.tag_v2[41493].denominator -def test_ifd_signed_rational(tmp_path): +def test_ifd_signed_rational(tmp_path: Path) -> None: im = hopper() info = TiffImagePlugin.ImageFileDirectory_v2() @@ -377,7 +387,7 @@ def test_ifd_signed_rational(tmp_path): assert -1 == reloaded.tag_v2[37380].denominator -def test_ifd_signed_long(tmp_path): +def test_ifd_signed_long(tmp_path: Path) -> None: im = hopper() info = TiffImagePlugin.ImageFileDirectory_v2() @@ -390,7 +400,7 @@ def test_ifd_signed_long(tmp_path): assert reloaded.tag_v2[37000] == -60000 -def test_empty_values(): +def test_empty_values() -> None: data = io.BytesIO( b"II*\x00\x08\x00\x00\x00\x03\x00\x1a\x01\x05\x00\x00\x00\x00\x00" b"\x00\x00\x00\x00\x1b\x01\x05\x00\x00\x00\x00\x00\x00\x00\x00\x00" @@ -405,7 +415,7 @@ def test_empty_values(): assert 33432 in info -def test_photoshop_info(tmp_path): +def test_photoshop_info(tmp_path: Path) -> None: with Image.open("Tests/images/issue_2278.tif") as im: assert len(im.tag_v2[34377]) == 70 assert isinstance(im.tag_v2[34377], bytes) @@ -416,7 +426,7 @@ def test_photoshop_info(tmp_path): assert isinstance(reloaded.tag_v2[34377], bytes) -def test_too_many_entries(): +def test_too_many_entries() -> None: ifd = TiffImagePlugin.ImageFileDirectory_v2() # 277: ("SamplesPerPixel", SHORT, 1), @@ -428,7 +438,7 @@ def test_too_many_entries(): assert ifd[277] == 4 -def test_tag_group_data(): +def test_tag_group_data() -> None: base_ifd = TiffImagePlugin.ImageFileDirectory_v2() interop_ifd = TiffImagePlugin.ImageFileDirectory_v2(group=40965) for ifd in (base_ifd, interop_ifd): @@ -442,7 +452,7 @@ def test_tag_group_data(): assert base_ifd.tagtype[2] != interop_ifd.tagtype[256] -def test_empty_subifd(tmp_path): +def test_empty_subifd(tmp_path: Path) -> None: out = str(tmp_path / "temp.jpg") im = hopper() diff --git a/Tests/test_file_wal.py b/Tests/test_file_wal.py index 0b84d0320..b34975e83 100644 --- a/Tests/test_file_wal.py +++ b/Tests/test_file_wal.py @@ -1,4 +1,5 @@ from __future__ import annotations + from PIL import WalImageFile from .helper import assert_image_equal_tofile @@ -6,7 +7,7 @@ from .helper import assert_image_equal_tofile TEST_FILE = "Tests/images/hopper.wal" -def test_open(): +def test_open() -> None: with WalImageFile.open(TEST_FILE) as im: assert im.format == "WAL" assert im.format_description == "Quake2 Texture" @@ -18,7 +19,7 @@ def test_open(): assert_image_equal_tofile(im, "Tests/images/hopper_wal.png") -def test_load(): +def test_load() -> None: with WalImageFile.open(TEST_FILE) as im: assert im.load()[0, 0] == 122 diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index c91818ef6..249846da4 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -1,8 +1,10 @@ from __future__ import annotations + import io import re import sys import warnings +from pathlib import Path import pytest @@ -25,7 +27,7 @@ except ImportError: class TestUnsupportedWebp: - def test_unsupported(self): + def test_unsupported(self) -> None: if HAVE_WEBP: WebPImagePlugin.SUPPORTED = False @@ -41,15 +43,15 @@ class TestUnsupportedWebp: @skip_unless_feature("webp") class TestFileWebp: - def setup_method(self): + def setup_method(self) -> None: self.rgb_mode = "RGB" - def test_version(self): + def test_version(self) -> None: _webp.WebPDecoderVersion() _webp.WebPDecoderBuggyAlpha() assert re.search(r"\d+\.\d+\.\d+$", features.version_module("webp")) - def test_read_rgb(self): + def test_read_rgb(self) -> None: """ Can we read a RGB mode WebP file without error? Does it have the bits we expect? @@ -66,7 +68,7 @@ class TestFileWebp: # dwebp -ppm ../../Tests/images/hopper.webp -o hopper_webp_bits.ppm assert_image_similar_tofile(image, "Tests/images/hopper_webp_bits.ppm", 1.0) - def _roundtrip(self, tmp_path, mode, epsilon, args={}): + def _roundtrip(self, tmp_path: Path, mode, epsilon, args={}) -> None: temp_file = str(tmp_path / "temp.webp") hopper(mode).save(temp_file, **args) @@ -92,7 +94,7 @@ class TestFileWebp: target = target.convert(self.rgb_mode) assert_image_similar(image, target, epsilon) - def test_write_rgb(self, tmp_path): + def test_write_rgb(self, tmp_path: Path) -> None: """ Can we write a RGB mode file to webp without error? Does it have the bits we expect? @@ -100,7 +102,7 @@ class TestFileWebp: self._roundtrip(tmp_path, self.rgb_mode, 12.5) - def test_write_method(self, tmp_path): + def test_write_method(self, tmp_path: Path) -> None: self._roundtrip(tmp_path, self.rgb_mode, 12.0, {"method": 6}) buffer_no_args = io.BytesIO() @@ -111,7 +113,7 @@ class TestFileWebp: assert buffer_no_args.getbuffer() != buffer_method.getbuffer() @skip_unless_feature("webp_anim") - def test_save_all(self, tmp_path): + def test_save_all(self, tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.webp") im = Image.new("RGB", (1, 1)) im2 = Image.new("RGB", (1, 1), "#f00") @@ -123,14 +125,14 @@ class TestFileWebp: reloaded.seek(1) assert_image_similar(im2, reloaded, 1) - def test_icc_profile(self, tmp_path): + def test_icc_profile(self, tmp_path: Path) -> None: self._roundtrip(tmp_path, self.rgb_mode, 12.5, {"icc_profile": None}) if _webp.HAVE_WEBPANIM: self._roundtrip( tmp_path, self.rgb_mode, 12.5, {"icc_profile": None, "save_all": True} ) - def test_write_unsupported_mode_L(self, tmp_path): + def test_write_unsupported_mode_L(self, tmp_path: Path) -> None: """ Saving a black-and-white file to WebP format should work, and be similar to the original file. @@ -138,7 +140,7 @@ class TestFileWebp: self._roundtrip(tmp_path, "L", 10.0) - def test_write_unsupported_mode_P(self, tmp_path): + def test_write_unsupported_mode_P(self, tmp_path: Path) -> None: """ Saving a palette-based file to WebP format should work, and be similar to the original file. @@ -147,14 +149,14 @@ class TestFileWebp: self._roundtrip(tmp_path, "P", 50.0) @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") - def test_write_encoding_error_message(self, tmp_path): + def test_write_encoding_error_message(self, tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.webp") im = Image.new("RGB", (15000, 15000)) with pytest.raises(ValueError) as e: im.save(temp_file, method=0) assert str(e.value) == "encoding error 6" - def test_WebPEncode_with_invalid_args(self): + def test_WebPEncode_with_invalid_args(self) -> None: """ Calling encoder functions with no arguments should result in an error. """ @@ -165,7 +167,7 @@ class TestFileWebp: with pytest.raises(TypeError): _webp.WebPEncode() - def test_WebPDecode_with_invalid_args(self): + def test_WebPDecode_with_invalid_args(self) -> None: """ Calling decoder functions with no arguments should result in an error. """ @@ -176,14 +178,14 @@ class TestFileWebp: with pytest.raises(TypeError): _webp.WebPDecode() - def test_no_resource_warning(self, tmp_path): + def test_no_resource_warning(self, tmp_path: Path) -> None: file_path = "Tests/images/hopper.webp" with Image.open(file_path) as image: temp_file = str(tmp_path / "temp.webp") with warnings.catch_warnings(): image.save(temp_file) - def test_file_pointer_could_be_reused(self): + def test_file_pointer_could_be_reused(self) -> None: file_path = "Tests/images/hopper.webp" with open(file_path, "rb") as blob: Image.open(blob).load() @@ -194,14 +196,14 @@ class TestFileWebp: (0, (0,), (-1, 0, 1, 2), (253, 254, 255, 256)), ) @skip_unless_feature("webp_anim") - def test_invalid_background(self, background, tmp_path): + def test_invalid_background(self, background, tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.webp") im = hopper() with pytest.raises(OSError): im.save(temp_file, save_all=True, append_images=[im], background=background) @skip_unless_feature("webp_anim") - def test_background_from_gif(self, tmp_path): + def test_background_from_gif(self, tmp_path: Path) -> None: # Save L mode GIF with background with Image.open("Tests/images/no_palette_with_background.gif") as im: out_webp = str(tmp_path / "temp.webp") @@ -226,7 +228,7 @@ class TestFileWebp: assert difference < 5 @skip_unless_feature("webp_anim") - def test_duration(self, tmp_path): + def test_duration(self, tmp_path: Path) -> None: with Image.open("Tests/images/dispose_bgnd.gif") as im: assert im.info["duration"] == 1000 @@ -237,7 +239,7 @@ class TestFileWebp: reloaded.load() assert reloaded.info["duration"] == 1000 - def test_roundtrip_rgba_palette(self, tmp_path): + def test_roundtrip_rgba_palette(self, tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.webp") im = Image.new("RGBA", (1, 1)).convert("P") assert im.mode == "P" diff --git a/Tests/test_file_webp_alpha.py b/Tests/test_file_webp_alpha.py index 79d01a444..a95434624 100644 --- a/Tests/test_file_webp_alpha.py +++ b/Tests/test_file_webp_alpha.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import Image @@ -13,12 +16,12 @@ from .helper import ( _webp = pytest.importorskip("PIL._webp", reason="WebP support not installed") -def setup_module(): +def setup_module() -> None: if _webp.WebPDecoderBuggyAlpha(): pytest.skip("Buggy early version of WebP installed, not testing transparency") -def test_read_rgba(): +def test_read_rgba() -> None: """ Can we read an RGBA mode file without error? Does it have the bits we expect? @@ -38,7 +41,7 @@ def test_read_rgba(): assert_image_similar_tofile(image, "Tests/images/transparent.png", 20.0) -def test_write_lossless_rgb(tmp_path): +def test_write_lossless_rgb(tmp_path: Path) -> None: """ Can we write an RGBA mode file with lossless compression without error? Does it have the bits we expect? @@ -67,7 +70,7 @@ def test_write_lossless_rgb(tmp_path): assert_image_equal(image, pil_image) -def test_write_rgba(tmp_path): +def test_write_rgba(tmp_path: Path) -> None: """ Can we write a RGBA mode file to WebP without error. Does it have the bits we expect? @@ -98,7 +101,7 @@ def test_write_rgba(tmp_path): assert_image_similar(image, pil_image, 1.0) -def test_keep_rgb_values_when_transparent(tmp_path): +def test_keep_rgb_values_when_transparent(tmp_path: Path) -> None: """ Saving transparent pixels should retain their original RGB values when using the "exact" parameter. @@ -127,7 +130,7 @@ def test_keep_rgb_values_when_transparent(tmp_path): assert_image_equal(reloaded.convert("RGB"), image) -def test_write_unsupported_mode_PA(tmp_path): +def test_write_unsupported_mode_PA(tmp_path: Path) -> None: """ Saving a palette-based file with transparency to WebP format should work, and be similar to the original file. diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py index 22acb4be6..9a730f1f9 100644 --- a/Tests/test_file_webp_animated.py +++ b/Tests/test_file_webp_animated.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from packaging.version import parse as parse_version @@ -17,7 +20,7 @@ pytestmark = [ ] -def test_n_frames(): +def test_n_frames() -> None: """Ensure that WebP format sets n_frames and is_animated attributes correctly.""" with Image.open("Tests/images/hopper.webp") as im: @@ -29,7 +32,7 @@ def test_n_frames(): assert im.is_animated -def test_write_animation_L(tmp_path): +def test_write_animation_L(tmp_path: Path) -> None: """ Convert an animated GIF to animated WebP, then compare the frame count, and first and last frames to ensure they're visually similar. @@ -59,13 +62,13 @@ def test_write_animation_L(tmp_path): assert_image_similar(im, orig.convert("RGBA"), 32.9) -def test_write_animation_RGB(tmp_path): +def test_write_animation_RGB(tmp_path: Path) -> None: """ Write an animated WebP from RGB frames, and ensure the frames are visually similar to the originals. """ - def check(temp_file): + def check(temp_file) -> None: with Image.open(temp_file) as im: assert im.n_frames == 2 @@ -104,7 +107,7 @@ def test_write_animation_RGB(tmp_path): check(temp_file2) -def test_timestamp_and_duration(tmp_path): +def test_timestamp_and_duration(tmp_path: Path) -> None: """ Try passing a list of durations, and make sure the encoded timestamps and durations are correct. @@ -135,7 +138,7 @@ def test_timestamp_and_duration(tmp_path): ts += durations[frame] -def test_float_duration(tmp_path): +def test_float_duration(tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.webp") with Image.open("Tests/images/iss634.apng") as im: assert im.info["duration"] == 70.0 @@ -147,7 +150,7 @@ def test_float_duration(tmp_path): assert reloaded.info["duration"] == 70 -def test_seeking(tmp_path): +def test_seeking(tmp_path: Path) -> None: """ Create an animated WebP file, and then try seeking through frames in reverse-order, verifying the timestamps and durations are correct. @@ -178,7 +181,7 @@ def test_seeking(tmp_path): ts -= dur -def test_seek_errors(): +def test_seek_errors() -> None: with Image.open("Tests/images/iss634.webp") as im: with pytest.raises(EOFError): im.seek(-1) diff --git a/Tests/test_file_webp_lossless.py b/Tests/test_file_webp_lossless.py index 6acf58ac3..32e29de56 100644 --- a/Tests/test_file_webp_lossless.py +++ b/Tests/test_file_webp_lossless.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import Image @@ -9,7 +12,7 @@ _webp = pytest.importorskip("PIL._webp", reason="WebP support not installed") RGB_MODE = "RGB" -def test_write_lossless_rgb(tmp_path): +def test_write_lossless_rgb(tmp_path: Path) -> None: if _webp.WebPDecoderVersion() < 0x0200: pytest.skip("lossless not included") diff --git a/Tests/test_file_webp_metadata.py b/Tests/test_file_webp_metadata.py index a7b7bbcf6..875941240 100644 --- a/Tests/test_file_webp_metadata.py +++ b/Tests/test_file_webp_metadata.py @@ -1,5 +1,8 @@ from __future__ import annotations + from io import BytesIO +from pathlib import Path +from types import ModuleType import pytest @@ -12,13 +15,14 @@ pytestmark = [ skip_unless_feature("webp_mux"), ] +ElementTree: ModuleType | None try: from defusedxml import ElementTree except ImportError: ElementTree = None -def test_read_exif_metadata(): +def test_read_exif_metadata() -> None: file_path = "Tests/images/flower.webp" with Image.open(file_path) as image: assert image.format == "WEBP" @@ -36,7 +40,7 @@ def test_read_exif_metadata(): assert exif_data == expected_exif -def test_read_exif_metadata_without_prefix(): +def test_read_exif_metadata_without_prefix() -> None: with Image.open("Tests/images/flower2.webp") as im: # Assert prefix is not present assert im.info["exif"][:6] != b"Exif\x00\x00" @@ -48,7 +52,7 @@ def test_read_exif_metadata_without_prefix(): @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) -def test_write_exif_metadata(): +def test_write_exif_metadata() -> None: file_path = "Tests/images/flower.jpg" test_buffer = BytesIO() with Image.open(file_path) as image: @@ -62,7 +66,7 @@ def test_write_exif_metadata(): assert webp_exif == expected_exif[6:], "WebP EXIF didn't match" -def test_read_icc_profile(): +def test_read_icc_profile() -> None: file_path = "Tests/images/flower2.webp" with Image.open(file_path) as image: assert image.format == "WEBP" @@ -79,7 +83,7 @@ def test_read_icc_profile(): @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) -def test_write_icc_metadata(): +def test_write_icc_metadata() -> None: file_path = "Tests/images/flower2.jpg" test_buffer = BytesIO() with Image.open(file_path) as image: @@ -99,7 +103,7 @@ def test_write_icc_metadata(): @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) -def test_read_no_exif(): +def test_read_no_exif() -> None: file_path = "Tests/images/flower.jpg" test_buffer = BytesIO() with Image.open(file_path) as image: @@ -112,7 +116,7 @@ def test_read_no_exif(): assert not webp_image._getexif() -def test_getxmp(): +def test_getxmp() -> None: with Image.open("Tests/images/flower.webp") as im: assert "xmp" not in im.info assert im.getxmp() == {} @@ -132,7 +136,7 @@ def test_getxmp(): @skip_unless_feature("webp_anim") -def test_write_animated_metadata(tmp_path): +def test_write_animated_metadata(tmp_path: Path) -> None: iccp_data = b"" exif_data = b"" xmp_data = b"" diff --git a/Tests/test_file_wmf.py b/Tests/test_file_wmf.py index 596dc8ba1..b43e3f296 100644 --- a/Tests/test_file_wmf.py +++ b/Tests/test_file_wmf.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import Image, WmfImagePlugin @@ -6,7 +9,7 @@ from PIL import Image, WmfImagePlugin from .helper import assert_image_similar_tofile, hopper -def test_load_raw(): +def test_load_raw() -> None: # Test basic EMF open and rendering with Image.open("Tests/images/drawing.emf") as im: if hasattr(Image.core, "drawwmf"): @@ -24,17 +27,17 @@ def test_load_raw(): assert_image_similar_tofile(im, "Tests/images/drawing_wmf_ref.png", 2.0) -def test_load(): +def test_load() -> None: with Image.open("Tests/images/drawing.emf") as im: if hasattr(Image.core, "drawwmf"): assert im.load()[0, 0] == (255, 255, 255) -def test_register_handler(tmp_path): +def test_register_handler(tmp_path: Path) -> None: class TestHandler: methodCalled = False - def save(self, im, fp, filename): + def save(self, im, fp, filename) -> None: self.methodCalled = True handler = TestHandler() @@ -50,12 +53,12 @@ def test_register_handler(tmp_path): WmfImagePlugin.register_handler(original_handler) -def test_load_float_dpi(): +def test_load_float_dpi() -> None: with Image.open("Tests/images/drawing.emf") as im: assert im.info["dpi"] == 1423.7668161434979 -def test_load_set_dpi(): +def test_load_set_dpi() -> None: with Image.open("Tests/images/drawing.wmf") as im: assert im.size == (82, 82) @@ -67,7 +70,7 @@ def test_load_set_dpi(): @pytest.mark.parametrize("ext", (".wmf", ".emf")) -def test_save(ext, tmp_path): +def test_save(ext, tmp_path: Path) -> None: im = hopper() tmpfile = str(tmp_path / ("temp" + ext)) diff --git a/Tests/test_file_xbm.py b/Tests/test_file_xbm.py index b086ffd68..44dd2541f 100644 --- a/Tests/test_file_xbm.py +++ b/Tests/test_file_xbm.py @@ -1,5 +1,7 @@ from __future__ import annotations + from io import BytesIO +from pathlib import Path import pytest @@ -31,14 +33,14 @@ static char basic_bits[] = { """ -def test_pil151(): +def test_pil151() -> None: with Image.open(BytesIO(PIL151)) as im: im.load() assert im.mode == "1" assert im.size == (32, 32) -def test_open(): +def test_open() -> None: # Arrange # Created with `convert hopper.png hopper.xbm` filename = "Tests/images/hopper.xbm" @@ -50,7 +52,7 @@ def test_open(): assert im.size == (128, 128) -def test_open_filename_with_underscore(): +def test_open_filename_with_underscore() -> None: # Arrange # Created with `convert hopper.png hopper_underscore.xbm` filename = "Tests/images/hopper_underscore.xbm" @@ -62,14 +64,14 @@ def test_open_filename_with_underscore(): assert im.size == (128, 128) -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): XbmImagePlugin.XbmImageFile(invalid_file) -def test_save_wrong_mode(tmp_path): +def test_save_wrong_mode(tmp_path: Path) -> None: im = hopper() out = str(tmp_path / "temp.xbm") @@ -77,7 +79,7 @@ def test_save_wrong_mode(tmp_path): im.save(out) -def test_hotspot(tmp_path): +def test_hotspot(tmp_path: Path) -> None: im = hopper("1") out = str(tmp_path / "temp.xbm") diff --git a/Tests/test_file_xpm.py b/Tests/test_file_xpm.py index 265feab42..26afe93f4 100644 --- a/Tests/test_file_xpm.py +++ b/Tests/test_file_xpm.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, XpmImagePlugin @@ -8,7 +9,7 @@ from .helper import assert_image_similar, hopper TEST_FILE = "Tests/images/hopper.xpm" -def test_sanity(): +def test_sanity() -> None: with Image.open(TEST_FILE) as im: im.load() assert im.mode == "P" @@ -19,14 +20,14 @@ def test_sanity(): assert_image_similar(im.convert("RGB"), hopper("RGB"), 60) -def test_invalid_file(): +def test_invalid_file() -> None: invalid_file = "Tests/images/flower.jpg" with pytest.raises(SyntaxError): XpmImagePlugin.XpmImageFile(invalid_file) -def test_load_read(): +def test_load_read() -> None: # Arrange with Image.open(TEST_FILE) as im: dummy_bytes = 1 diff --git a/Tests/test_file_xvthumb.py b/Tests/test_file_xvthumb.py index 5848995c1..6b8115930 100644 --- a/Tests/test_file_xvthumb.py +++ b/Tests/test_file_xvthumb.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, XVThumbImagePlugin @@ -8,7 +9,7 @@ from .helper import assert_image_similar, hopper TEST_FILE = "Tests/images/hopper.p7" -def test_open(): +def test_open() -> None: # Act with Image.open(TEST_FILE) as im: # Assert @@ -19,7 +20,7 @@ def test_open(): assert_image_similar(im, im_hopper, 9) -def test_unexpected_eof(): +def test_unexpected_eof() -> None: # Test unexpected EOF reading XV thumbnail file # Arrange bad_file = "Tests/images/hopper_bad.p7" @@ -29,7 +30,7 @@ def test_unexpected_eof(): XVThumbImagePlugin.XVThumbImageFile(bad_file) -def test_invalid_file(): +def test_invalid_file() -> None: # Arrange invalid_file = "Tests/images/flower.jpg" diff --git a/Tests/test_font_bdf.py b/Tests/test_font_bdf.py index 1e5eff2f1..136070f9e 100644 --- a/Tests/test_font_bdf.py +++ b/Tests/test_font_bdf.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import BdfFontFile, FontFile @@ -6,7 +7,7 @@ from PIL import BdfFontFile, FontFile filename = "Tests/images/courB08.bdf" -def test_sanity(): +def test_sanity() -> None: with open(filename, "rb") as test_file: font = BdfFontFile.BdfFontFile(test_file) @@ -14,7 +15,7 @@ def test_sanity(): assert len([_f for _f in font.glyph if _f]) == 190 -def test_invalid_file(): +def test_invalid_file() -> None: with open("Tests/images/flower.jpg", "rb") as fp: with pytest.raises(SyntaxError): BdfFontFile.BdfFontFile(fp) diff --git a/Tests/test_font_crash.py b/Tests/test_font_crash.py index 388ee7118..b82340ef7 100644 --- a/Tests/test_font_crash.py +++ b/Tests/test_font_crash.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, ImageDraw, ImageFont @@ -7,7 +8,7 @@ from .helper import skip_unless_feature class TestFontCrash: - def _fuzz_font(self, font): + def _fuzz_font(self, font: ImageFont.FreeTypeFont) -> None: # from fuzzers.fuzz_font font.getbbox("ABC") font.getmask("test text") @@ -17,7 +18,7 @@ class TestFontCrash: draw.text((10, 10), "Test Text", font=font, fill="#000") @skip_unless_feature("freetype2") - def test_segfault(self): + def test_segfault(self) -> None: with pytest.raises(OSError): font = ImageFont.truetype("Tests/fonts/fuzz_font-5203009437302784") self._fuzz_font(font) diff --git a/Tests/test_font_leaks.py b/Tests/test_font_leaks.py index 6a038bb40..08a0e7431 100644 --- a/Tests/test_font_leaks.py +++ b/Tests/test_font_leaks.py @@ -1,7 +1,10 @@ from __future__ import annotations -from PIL import Image, ImageDraw, ImageFont -from .helper import PillowLeakTestCase, skip_unless_feature +from PIL import Image, ImageDraw, ImageFont, _util + +from .helper import PillowLeakTestCase, features, skip_unless_feature + +original_core = ImageFont.core class TestTTypeFontLeak(PillowLeakTestCase): @@ -9,7 +12,7 @@ class TestTTypeFontLeak(PillowLeakTestCase): iterations = 10 mem_limit = 4096 # k - def _test_font(self, font): + def _test_font(self, font: ImageFont.FreeTypeFont) -> None: im = Image.new("RGB", (255, 255), "white") draw = ImageDraw.ImageDraw(im) self._test_leak( @@ -19,7 +22,7 @@ class TestTTypeFontLeak(PillowLeakTestCase): ) @skip_unless_feature("freetype2") - def test_leak(self): + def test_leak(self) -> None: ttype = ImageFont.truetype("Tests/fonts/FreeMono.ttf", 20) self._test_font(ttype) @@ -29,6 +32,12 @@ class TestDefaultFontLeak(TestTTypeFontLeak): iterations = 100 mem_limit = 1024 # k - def test_leak(self): - default_font = ImageFont.load_default() + def test_leak(self) -> None: + if features.check_module("freetype2"): + ImageFont.core = _util.DeferredError(ImportError) + try: + default_font = ImageFont.load_default() + finally: + ImageFont.core = original_core + self._test_font(default_font) diff --git a/Tests/test_font_pcf.py b/Tests/test_font_pcf.py index 4365b9310..997809e46 100644 --- a/Tests/test_font_pcf.py +++ b/Tests/test_font_pcf.py @@ -1,5 +1,7 @@ from __future__ import annotations + import os +from pathlib import Path import pytest @@ -19,7 +21,7 @@ message = "hello, world" pytestmark = skip_unless_feature("zlib") -def save_font(request, tmp_path): +def save_font(request: pytest.FixtureRequest, tmp_path: Path) -> str: with open(fontname, "rb") as test_file: font = PcfFontFile.PcfFontFile(test_file) assert isinstance(font, FontFile.FontFile) @@ -28,7 +30,7 @@ def save_font(request, tmp_path): tempname = str(tmp_path / "temp.pil") - def delete_tempfile(): + def delete_tempfile() -> None: try: os.remove(tempname[:-4] + ".pbm") except OSError: @@ -46,11 +48,11 @@ def save_font(request, tmp_path): return tempname -def test_sanity(request, tmp_path): +def test_sanity(request: pytest.FixtureRequest, tmp_path: Path) -> None: save_font(request, tmp_path) -def test_less_than_256_characters(): +def test_less_than_256_characters() -> None: with open("Tests/fonts/10x20-ISO8859-1-fewer-characters.pcf", "rb") as test_file: font = PcfFontFile.PcfFontFile(test_file) assert isinstance(font, FontFile.FontFile) @@ -58,13 +60,13 @@ def test_less_than_256_characters(): assert len([_f for _f in font.glyph if _f]) == 127 -def test_invalid_file(): +def test_invalid_file() -> None: with open("Tests/images/flower.jpg", "rb") as fp: with pytest.raises(SyntaxError): PcfFontFile.PcfFontFile(fp) -def test_draw(request, tmp_path): +def test_draw(request: pytest.FixtureRequest, tmp_path: Path) -> None: tempname = save_font(request, tmp_path) font = ImageFont.load(tempname) im = Image.new("L", (130, 30), "white") @@ -73,7 +75,7 @@ def test_draw(request, tmp_path): assert_image_similar_tofile(im, "Tests/images/test_draw_pbm_target.png", 0) -def test_textsize(request, tmp_path): +def test_textsize(request: pytest.FixtureRequest, tmp_path: Path) -> None: tempname = save_font(request, tmp_path) font = ImageFont.load(tempname) for i in range(255): @@ -89,7 +91,9 @@ def test_textsize(request, tmp_path): assert font.getbbox(msg) == (0, 0, len(msg) * 10, 20) -def _test_high_characters(request, tmp_path, message): +def _test_high_characters( + request: pytest.FixtureRequest, tmp_path: Path, message: str | bytes +) -> None: tempname = save_font(request, tmp_path) font = ImageFont.load(tempname) im = Image.new("L", (750, 30), "white") @@ -98,7 +102,7 @@ def _test_high_characters(request, tmp_path, message): assert_image_similar_tofile(im, "Tests/images/high_ascii_chars.png", 0) -def test_high_characters(request, tmp_path): +def test_high_characters(request: pytest.FixtureRequest, tmp_path: Path) -> None: message = "".join(chr(i + 1) for i in range(140, 232)) _test_high_characters(request, tmp_path, message) # accept bytes instances. diff --git a/Tests/test_font_pcf_charsets.py b/Tests/test_font_pcf_charsets.py index 950e5029f..895458d9d 100644 --- a/Tests/test_font_pcf_charsets.py +++ b/Tests/test_font_pcf_charsets.py @@ -1,5 +1,8 @@ from __future__ import annotations + import os +from pathlib import Path +from typing import TypedDict import pytest @@ -13,7 +16,14 @@ from .helper import ( fontname = "Tests/fonts/ter-x20b.pcf" -charsets = { + +class Charset(TypedDict): + glyph_count: int + message: str + image1: str + + +charsets: dict[str, Charset] = { "iso8859-1": { "glyph_count": 223, "message": "hello, world", @@ -35,7 +45,7 @@ charsets = { pytestmark = skip_unless_feature("zlib") -def save_font(request, tmp_path, encoding): +def save_font(request: pytest.FixtureRequest, tmp_path: Path, encoding: str) -> str: with open(fontname, "rb") as test_file: font = PcfFontFile.PcfFontFile(test_file, encoding) assert isinstance(font, FontFile.FontFile) @@ -44,7 +54,7 @@ def save_font(request, tmp_path, encoding): tempname = str(tmp_path / "temp.pil") - def delete_tempfile(): + def delete_tempfile() -> None: try: os.remove(tempname[:-4] + ".pbm") except OSError: @@ -63,12 +73,12 @@ def save_font(request, tmp_path, encoding): @pytest.mark.parametrize("encoding", ("iso8859-1", "iso8859-2", "cp1250")) -def test_sanity(request, tmp_path, encoding): +def test_sanity(request: pytest.FixtureRequest, tmp_path: Path, encoding: str) -> None: save_font(request, tmp_path, encoding) @pytest.mark.parametrize("encoding", ("iso8859-1", "iso8859-2", "cp1250")) -def test_draw(request, tmp_path, encoding): +def test_draw(request: pytest.FixtureRequest, tmp_path: Path, encoding: str) -> None: tempname = save_font(request, tmp_path, encoding) font = ImageFont.load(tempname) im = Image.new("L", (150, 30), "white") @@ -79,7 +89,9 @@ def test_draw(request, tmp_path, encoding): @pytest.mark.parametrize("encoding", ("iso8859-1", "iso8859-2", "cp1250")) -def test_textsize(request, tmp_path, encoding): +def test_textsize( + request: pytest.FixtureRequest, tmp_path: Path, encoding: str +) -> None: tempname = save_font(request, tmp_path, encoding) font = ImageFont.load(tempname) for i in range(255): diff --git a/Tests/test_fontfile.py b/Tests/test_fontfile.py new file mode 100644 index 000000000..206499a04 --- /dev/null +++ b/Tests/test_fontfile.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from PIL import FontFile + + +def test_save(tmp_path: Path) -> None: + tempname = str(tmp_path / "temp.pil") + + font = FontFile.FontFile() + with pytest.raises(ValueError): + font.save(tempname) diff --git a/Tests/test_format_hsv.py b/Tests/test_format_hsv.py index fd47fae39..c07024a2c 100644 --- a/Tests/test_format_hsv.py +++ b/Tests/test_format_hsv.py @@ -1,30 +1,28 @@ from __future__ import annotations + import colorsys import itertools +from typing import Callable from PIL import Image from .helper import assert_image_similar, hopper -def int_to_float(i): +def int_to_float(i: int) -> float: return i / 255 -def str_to_float(i): - return ord(i) / 255 - - -def tuple_to_ints(tp): +def tuple_to_ints(tp: tuple[float, float, float]) -> tuple[int, int, int]: x, y, z = tp return int(x * 255.0), int(y * 255.0), int(z * 255.0) -def test_sanity(): +def test_sanity() -> None: Image.new("HSV", (100, 100)) -def wedge(): +def wedge() -> Image.Image: w = Image._wedge() w90 = w.rotate(90) @@ -48,7 +46,11 @@ def wedge(): return img -def to_xxx_colorsys(im, func, mode): +def to_xxx_colorsys( + im: Image.Image, + func: Callable[[float, float, float], tuple[float, float, float]], + mode: str, +) -> Image.Image: # convert the hard way using the library colorsys routines. (r, g, b) = im.split() @@ -69,15 +71,15 @@ def to_xxx_colorsys(im, func, mode): return hsv -def to_hsv_colorsys(im): +def to_hsv_colorsys(im: Image.Image) -> Image.Image: return to_xxx_colorsys(im, colorsys.rgb_to_hsv, "HSV") -def to_rgb_colorsys(im): +def to_rgb_colorsys(im: Image.Image) -> Image.Image: return to_xxx_colorsys(im, colorsys.hsv_to_rgb, "RGB") -def test_wedge(): +def test_wedge() -> None: src = wedge().resize((3 * 32, 32), Image.Resampling.BILINEAR) im = src.convert("HSV") comparable = to_hsv_colorsys(src) @@ -109,7 +111,7 @@ def test_wedge(): ) -def test_convert(): +def test_convert() -> None: im = hopper("RGB").convert("HSV") comparable = to_hsv_colorsys(hopper("RGB")) @@ -127,7 +129,7 @@ def test_convert(): ) -def test_hsv_to_rgb(): +def test_hsv_to_rgb() -> None: comparable = to_hsv_colorsys(hopper("RGB")) converted = comparable.convert("RGB") comparable = to_rgb_colorsys(comparable) diff --git a/Tests/test_format_lab.py b/Tests/test_format_lab.py index c7610ce8a..4fcc37e88 100644 --- a/Tests/test_format_lab.py +++ b/Tests/test_format_lab.py @@ -1,8 +1,9 @@ from __future__ import annotations + from PIL import Image -def test_white(): +def test_white() -> None: with Image.open("Tests/images/lab.tif") as i: i.load() @@ -23,7 +24,7 @@ def test_white(): assert list(b) == [128] * 100 -def test_green(): +def test_green() -> None: # l= 50 (/100), a = -100 (-128 .. 128) b=0 in PS # == RGB: 0, 152, 117 with Image.open("Tests/images/lab-green.tif") as i: @@ -31,7 +32,7 @@ def test_green(): assert k == (128, 28, 128) -def test_red(): +def test_red() -> None: # l= 50 (/100), a = 100 (-128 .. 128) b=0 in PS # == RGB: 255, 0, 124 with Image.open("Tests/images/lab-red.tif") as i: diff --git a/Tests/test_image.py b/Tests/test_image.py index 615e00e40..d472bd7ea 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -1,4 +1,5 @@ from __future__ import annotations + import io import logging import os @@ -6,6 +7,8 @@ import shutil import sys import tempfile import warnings +from pathlib import Path +from typing import IO import pytest @@ -13,6 +16,7 @@ from PIL import ( ExifTags, Image, ImageDraw, + ImageFile, ImagePalette, UnidentifiedImageError, features, @@ -59,19 +63,19 @@ class TestImage: "HSV", ), ) - def test_image_modes_success(self, mode): + def test_image_modes_success(self, mode: str) -> None: Image.new(mode, (1, 1)) @pytest.mark.parametrize("mode", ("", "bad", "very very long")) - def test_image_modes_fail(self, mode): + def test_image_modes_fail(self, mode: str) -> None: with pytest.raises(ValueError) as e: Image.new(mode, (1, 1)) assert str(e.value) == "unrecognized image mode" - def test_exception_inheritance(self): + def test_exception_inheritance(self) -> None: assert issubclass(UnidentifiedImageError, OSError) - def test_sanity(self): + def test_sanity(self) -> None: im = Image.new("L", (100, 100)) assert repr(im)[:45] == " None: class Pretty: - def text(self, text): + def text(self, text: str) -> None: self.pretty_output = text im = Image.new("L", (100, 100)) @@ -107,7 +111,7 @@ class TestImage: im._repr_pretty_(p, None) assert p.pretty_output == "" - def test_open_formats(self): + def test_open_formats(self) -> None: PNGFILE = "Tests/images/hopper.png" JPGFILE = "Tests/images/hopper.jpg" @@ -129,39 +133,37 @@ class TestImage: assert im.mode == "RGB" assert im.size == (128, 128) - def test_width_height(self): + def test_width_height(self) -> None: im = Image.new("RGB", (1, 2)) assert im.width == 1 assert im.height == 2 with pytest.raises(AttributeError): - im.size = (3, 4) + im.size = (3, 4) # type: ignore[misc] - def test_set_mode(self): + def test_set_mode(self) -> None: im = Image.new("RGB", (1, 1)) with pytest.raises(AttributeError): - im.mode = "P" + im.mode = "P" # type: ignore[misc] - def test_invalid_image(self): + def test_invalid_image(self) -> None: im = io.BytesIO(b"") with pytest.raises(UnidentifiedImageError): with Image.open(im): pass - def test_bad_mode(self): + def test_bad_mode(self) -> None: with pytest.raises(ValueError): with Image.open("filename", "bad mode"): pass - def test_stringio(self): + def test_stringio(self) -> None: with pytest.raises(ValueError): with Image.open(io.StringIO()): pass - def test_pathlib(self, tmp_path): - from PIL.Image import Path - + def test_pathlib(self, tmp_path: Path) -> None: with Image.open(Path("Tests/images/multipage-mmap.tiff")) as im: assert im.mode == "P" assert im.size == (10, 10) @@ -178,11 +180,13 @@ class TestImage: os.remove(temp_file) im.save(Path(temp_file)) - def test_fp_name(self, tmp_path): + def test_fp_name(self, tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.jpg") class FP: - def write(self, b): + name: str + + def write(self, b: bytes) -> None: pass fp = FP() @@ -191,7 +195,7 @@ class TestImage: im = hopper() im.save(fp) - def test_tempfile(self): + def test_tempfile(self) -> None: # see #1460, pathlib support breaks tempfile.TemporaryFile on py27 # Will error out on save on 3.0.0 im = hopper() @@ -200,13 +204,13 @@ class TestImage: fp.seek(0) assert_image_similar_tofile(im, fp, 20) - def test_unknown_extension(self, tmp_path): + def test_unknown_extension(self, tmp_path: Path) -> None: im = hopper() temp_file = str(tmp_path / "temp.unknown") with pytest.raises(ValueError): im.save(temp_file) - def test_internals(self): + def test_internals(self) -> None: im = Image.new("L", (100, 100)) im.readonly = 1 im._copy() @@ -221,7 +225,7 @@ class TestImage: sys.platform == "cygwin", reason="Test requires opening an mmaped file for writing", ) - def test_readonly_save(self, tmp_path): + def test_readonly_save(self, tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.bmp") shutil.copy("Tests/images/rgb32bf-rgba.bmp", temp_file) @@ -229,7 +233,7 @@ class TestImage: assert im.readonly im.save(temp_file) - def test_dump(self, tmp_path): + def test_dump(self, tmp_path: Path) -> None: im = Image.new("L", (10, 10)) im._dump(str(tmp_path / "temp_L.ppm")) @@ -240,7 +244,7 @@ class TestImage: with pytest.raises(ValueError): im._dump(str(tmp_path / "temp_HSV.ppm")) - def test_comparison_with_other_type(self): + def test_comparison_with_other_type(self) -> None: # Arrange item = Image.new("RGB", (25, 25), "#000") num = 12 @@ -250,7 +254,7 @@ class TestImage: assert item is not None assert item != num - def test_expand_x(self): + def test_expand_x(self) -> None: # Arrange im = hopper() orig_size = im.size @@ -263,7 +267,7 @@ class TestImage: assert im.size[0] == orig_size[0] + 2 * xmargin assert im.size[1] == orig_size[1] + 2 * xmargin - def test_expand_xy(self): + def test_expand_xy(self) -> None: # Arrange im = hopper() orig_size = im.size @@ -277,12 +281,12 @@ class TestImage: assert im.size[0] == orig_size[0] + 2 * xmargin assert im.size[1] == orig_size[1] + 2 * ymargin - def test_getbands(self): + def test_getbands(self) -> None: # Assert assert hopper("RGB").getbands() == ("R", "G", "B") assert hopper("YCbCr").getbands() == ("Y", "Cb", "Cr") - def test_getchannel_wrong_params(self): + def test_getchannel_wrong_params(self) -> None: im = hopper() with pytest.raises(ValueError): @@ -294,7 +298,7 @@ class TestImage: with pytest.raises(ValueError): im.getchannel("1") - def test_getchannel(self): + def test_getchannel(self) -> None: im = hopper("YCbCr") Y, Cb, Cr = im.split() @@ -305,7 +309,7 @@ class TestImage: assert_image_equal(Cr, im.getchannel(2)) assert_image_equal(Cr, im.getchannel("Cr")) - def test_getbbox(self): + def test_getbbox(self) -> None: # Arrange im = hopper() @@ -315,7 +319,7 @@ class TestImage: # Assert assert bbox == (0, 0, 128, 128) - def test_ne(self): + def test_ne(self) -> None: # Arrange im1 = Image.new("RGB", (25, 25), "black") im2 = Image.new("RGB", (25, 25), "white") @@ -323,7 +327,7 @@ class TestImage: # Act / Assert assert im1 != im2 - def test_alpha_composite(self): + def test_alpha_composite(self) -> None: # https://stackoverflow.com/questions/3374878 # Arrange expected_colors = sorted( @@ -354,7 +358,7 @@ class TestImage: img_colors = sorted(img.getcolors()) assert img_colors == expected_colors - def test_alpha_inplace(self): + def test_alpha_inplace(self) -> None: src = Image.new("RGBA", (128, 128), "blue") over = Image.new("RGBA", (128, 128), "red") @@ -406,7 +410,7 @@ class TestImage: with pytest.raises(ValueError): source.alpha_composite(over, (0, 0), (0, -1)) - def test_register_open_duplicates(self): + def test_register_open_duplicates(self) -> None: # Arrange factory, accept = Image.OPEN["JPEG"] id_length = len(Image.ID) @@ -417,7 +421,7 @@ class TestImage: # Assert assert len(Image.ID) == id_length - def test_registered_extensions_uninitialized(self): + def test_registered_extensions_uninitialized(self) -> None: # Arrange Image._initialized = 0 @@ -427,7 +431,7 @@ class TestImage: # Assert assert Image._initialized == 2 - def test_registered_extensions(self): + def test_registered_extensions(self) -> None: # Arrange # Open an image to trigger plugin registration with Image.open("Tests/images/rgb.jpg"): @@ -441,7 +445,7 @@ class TestImage: for ext in [".cur", ".icns", ".tif", ".tiff"]: assert ext in extensions - def test_effect_mandelbrot(self): + def test_effect_mandelbrot(self) -> None: # Arrange size = (512, 512) extent = (-3, -2.5, 2, 2.5) @@ -454,7 +458,7 @@ class TestImage: assert im.size == (512, 512) assert_image_equal_tofile(im, "Tests/images/effect_mandelbrot.png") - def test_effect_mandelbrot_bad_arguments(self): + def test_effect_mandelbrot_bad_arguments(self) -> None: # Arrange size = (512, 512) # Get coordinates the wrong way round: @@ -466,7 +470,7 @@ class TestImage: with pytest.raises(ValueError): Image.effect_mandelbrot(size, extent, quality) - def test_effect_noise(self): + def test_effect_noise(self) -> None: # Arrange size = (100, 100) sigma = 128 @@ -484,7 +488,7 @@ class TestImage: p4 = im.getpixel((0, 4)) assert_not_all_same([p0, p1, p2, p3, p4]) - def test_effect_spread(self): + def test_effect_spread(self) -> None: # Arrange im = hopper() distance = 10 @@ -496,7 +500,7 @@ class TestImage: assert im.size == (128, 128) assert_image_similar_tofile(im2, "Tests/images/effect_spread.png", 110) - def test_effect_spread_zero(self): + def test_effect_spread_zero(self) -> None: # Arrange im = hopper() distance = 0 @@ -507,7 +511,7 @@ class TestImage: # Assert assert_image_equal(im, im2) - def test_check_size(self): + def test_check_size(self) -> None: # Checking that the _check_size function throws value errors when we want it to with pytest.raises(ValueError): Image.new("RGB", 0) # not a tuple @@ -536,10 +540,10 @@ class TestImage: "PILLOW_VALGRIND_TEST" in os.environ, reason="Valgrind is slower" ) @pytest.mark.parametrize("size", ((0, 100000000), (100000000, 0))) - def test_empty_image(self, size): + def test_empty_image(self, size: tuple[int, int]) -> None: Image.new("RGB", size) - def test_storage_neg(self): + def test_storage_neg(self) -> None: # Storage.c accepted negative values for xsize, ysize. Was # test_neg_ppm, but the core function for that has been # removed Calling directly into core to test the error in @@ -548,13 +552,13 @@ class TestImage: with pytest.raises(ValueError): Image.core.fill("RGB", (2, -2), (0, 0, 0)) - def test_one_item_tuple(self): + def test_one_item_tuple(self) -> None: for mode in ("I", "F", "L"): im = Image.new(mode, (100, 100), (5,)) px = im.load() assert px[0, 0] == 5 - def test_linear_gradient_wrong_mode(self): + def test_linear_gradient_wrong_mode(self) -> None: # Arrange wrong_mode = "RGB" @@ -563,7 +567,7 @@ class TestImage: Image.linear_gradient(wrong_mode) @pytest.mark.parametrize("mode", ("L", "P", "I", "F")) - def test_linear_gradient(self, mode): + def test_linear_gradient(self, mode: str) -> None: # Arrange target_file = "Tests/images/linear_gradient.png" @@ -579,7 +583,7 @@ class TestImage: target = target.convert(mode) assert_image_equal(im, target) - def test_radial_gradient_wrong_mode(self): + def test_radial_gradient_wrong_mode(self) -> None: # Arrange wrong_mode = "RGB" @@ -588,7 +592,7 @@ class TestImage: Image.radial_gradient(wrong_mode) @pytest.mark.parametrize("mode", ("L", "P", "I", "F")) - def test_radial_gradient(self, mode): + def test_radial_gradient(self, mode: str) -> None: # Arrange target_file = "Tests/images/radial_gradient.png" @@ -604,7 +608,7 @@ class TestImage: target = target.convert(mode) assert_image_equal(im, target) - def test_register_extensions(self): + def test_register_extensions(self) -> None: test_format = "a" exts = ["b", "c"] for ext in exts: @@ -620,7 +624,7 @@ class TestImage: assert ext_individual == ext_multiple - def test_remap_palette(self): + def test_remap_palette(self) -> None: # Test identity transform with Image.open("Tests/images/hopper.gif") as im: assert_image_equal(im, im.remap_palette(list(range(256)))) @@ -639,7 +643,7 @@ class TestImage: with pytest.raises(ValueError): im.remap_palette(None) - def test_remap_palette_transparency(self): + def test_remap_palette_transparency(self) -> None: im = Image.new("P", (1, 2), (0, 0, 0)) im.putpixel((0, 1), (255, 0, 0)) im.info["transparency"] = 0 @@ -654,7 +658,7 @@ class TestImage: im_remapped = im.remap_palette([1, 0]) assert "transparency" not in im_remapped.info - def test__new(self): + def test__new(self) -> None: im = hopper("RGB") im_p = hopper("P") @@ -663,7 +667,11 @@ class TestImage: blank_p.palette = None blank_pa.palette = None - def _make_new(base_image, image, palette_result=None): + def _make_new( + base_image: Image.Image, + image: Image.Image, + palette_result: ImagePalette.ImagePalette | None = None, + ) -> None: new_image = base_image._new(image.im) assert new_image.mode == image.mode assert new_image.size == image.size @@ -678,17 +686,20 @@ class TestImage: _make_new(im, blank_p, ImagePalette.ImagePalette()) _make_new(im, blank_pa, ImagePalette.ImagePalette()) - def test_p_from_rgb_rgba(self): - for mode, color in [ + @pytest.mark.parametrize( + "mode, color", + ( ("RGB", "#DDEEFF"), ("RGB", (221, 238, 255)), ("RGBA", (221, 238, 255, 255)), - ]: - im = Image.new("P", (100, 100), color) - expected = Image.new(mode, (100, 100), color) - assert_image_equal(im.convert(mode), expected) + ), + ) + def test_p_from_rgb_rgba(self, mode: str, color: str | tuple[int, ...]) -> None: + im = Image.new("P", (100, 100), color) + expected = Image.new(mode, (100, 100), color) + assert_image_equal(im.convert(mode), expected) - def test_no_resource_warning_on_save(self, tmp_path): + def test_no_resource_warning_on_save(self, tmp_path: Path) -> None: # https://github.com/python-pillow/Pillow/issues/835 # Arrange test_file = "Tests/images/hopper.png" @@ -699,7 +710,7 @@ class TestImage: with warnings.catch_warnings(): im.save(temp_file) - def test_no_new_file_on_error(self, tmp_path): + def test_no_new_file_on_error(self, tmp_path: Path) -> None: temp_file = str(tmp_path / "temp.jpg") im = Image.new("RGB", (0, 0)) @@ -708,10 +719,10 @@ class TestImage: assert not os.path.exists(temp_file) - def test_load_on_nonexclusive_multiframe(self): + def test_load_on_nonexclusive_multiframe(self) -> None: with open("Tests/images/frozenpond.mpo", "rb") as fp: - def act(fp): + def act(fp: IO[bytes]) -> None: im = Image.open(fp) im.load() @@ -722,7 +733,7 @@ class TestImage: assert not fp.closed - def test_empty_exif(self): + def test_empty_exif(self) -> None: with Image.open("Tests/images/exif.png") as im: exif = im.getexif() assert dict(exif) @@ -738,7 +749,7 @@ class TestImage: @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_exif_jpeg(self, tmp_path): + def test_exif_jpeg(self, tmp_path: Path) -> None: with Image.open("Tests/images/exif-72dpi-int.jpg") as im: # Little endian exif = im.getexif() assert 258 not in exif @@ -784,7 +795,7 @@ class TestImage: @skip_unless_feature("webp") @skip_unless_feature("webp_anim") - def test_exif_webp(self, tmp_path): + def test_exif_webp(self, tmp_path: Path) -> None: with Image.open("Tests/images/hopper.webp") as im: exif = im.getexif() assert exif == {} @@ -794,7 +805,7 @@ class TestImage: exif[40963] = 455 exif[305] = "Pillow test" - def check_exif(): + def check_exif() -> None: with Image.open(out) as reloaded: reloaded_exif = reloaded.getexif() assert reloaded_exif[258] == 8 @@ -806,7 +817,7 @@ class TestImage: im.save(out, exif=exif, save_all=True) check_exif() - def test_exif_png(self, tmp_path): + def test_exif_png(self, tmp_path: Path) -> None: with Image.open("Tests/images/exif.png") as im: exif = im.getexif() assert exif == {274: 1} @@ -822,7 +833,7 @@ class TestImage: reloaded_exif = reloaded.getexif() assert reloaded_exif == {258: 8, 40963: 455, 305: "Pillow test"} - def test_exif_interop(self): + def test_exif_interop(self) -> None: with Image.open("Tests/images/flower.jpg") as im: exif = im.getexif() assert exif.get_ifd(0xA005) == { @@ -836,7 +847,7 @@ class TestImage: reloaded_exif.load(exif.tobytes()) assert reloaded_exif.get_ifd(0xA005) == exif.get_ifd(0xA005) - def test_exif_ifd1(self): + def test_exif_ifd1(self) -> None: with Image.open("Tests/images/flower.jpg") as im: exif = im.getexif() assert exif.get_ifd(ExifTags.IFD.IFD1) == { @@ -848,7 +859,7 @@ class TestImage: 283: 180.0, } - def test_exif_ifd(self): + def test_exif_ifd(self) -> None: with Image.open("Tests/images/flower.jpg") as im: exif = im.getexif() del exif.get_ifd(0x8769)[0xA005] @@ -857,7 +868,7 @@ class TestImage: reloaded_exif.load(exif.tobytes()) assert reloaded_exif.get_ifd(0x8769) == exif.get_ifd(0x8769) - def test_exif_load_from_fp(self): + def test_exif_load_from_fp(self) -> None: with Image.open("Tests/images/flower.jpg") as im: data = im.info["exif"] if data.startswith(b"Exif\x00\x00"): @@ -878,7 +889,7 @@ class TestImage: 34665: 196, } - def test_exif_hide_offsets(self): + def test_exif_hide_offsets(self) -> None: with Image.open("Tests/images/flower.jpg") as im: exif = im.getexif() @@ -904,18 +915,18 @@ class TestImage: assert exif.get_ifd(0xA005) @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) - def test_zero_tobytes(self, size): + def test_zero_tobytes(self, size: tuple[int, int]) -> None: im = Image.new("RGB", size) assert im.tobytes() == b"" @pytest.mark.parametrize("size", ((1, 0), (0, 1), (0, 0))) - def test_zero_frombytes(self, size): + def test_zero_frombytes(self, size: tuple[int, int]) -> None: Image.frombytes("RGB", size, b"") im = Image.new("RGB", size) im.frombytes(b"") - def test_has_transparency_data(self): + def test_has_transparency_data(self) -> None: for mode in ("1", "L", "P", "RGB"): im = Image.new(mode, (1, 1)) assert not im.has_transparency_data @@ -940,7 +951,7 @@ class TestImage: assert im.palette.mode == "RGBA" assert im.has_transparency_data - def test_apply_transparency(self): + def test_apply_transparency(self) -> None: im = Image.new("P", (1, 1)) im.putpalette((0, 0, 0, 1, 1, 1)) assert im.palette.colors == {(0, 0, 0): 0, (1, 1, 1): 1} @@ -969,7 +980,7 @@ class TestImage: im.apply_transparency() assert im.palette.colors[(27, 35, 6, 214)] == 24 - def test_constants(self): + def test_constants(self) -> None: for enum in ( Image.Transpose, Image.Transform, @@ -994,7 +1005,7 @@ class TestImage: "01r_00.pcx", ], ) - def test_overrun(self, path): + def test_overrun(self, path: str) -> None: """For overrun completeness, test as: valgrind pytest -qq Tests/test_image.py::TestImage::test_overrun | grep decode.c """ @@ -1008,7 +1019,7 @@ class TestImage: assert buffer_overrun or truncated - def test_fli_overrun2(self): + def test_fli_overrun2(self) -> None: with Image.open("Tests/images/fli_overrun2.bin") as im: try: im.seek(1) @@ -1016,7 +1027,12 @@ class TestImage: except OSError as e: assert str(e) == "buffer overrun when reading image file" - def test_close_graceful(self, caplog): + def test_exit_fp(self) -> None: + with Image.new("L", (1, 1)) as im: + pass + assert not hasattr(im, "fp") + + def test_close_graceful(self, caplog: pytest.LogCaptureFixture) -> None: with Image.open("Tests/images/hopper.jpg") as im: copy = im.copy() with caplog.at_level(logging.DEBUG): @@ -1026,26 +1042,21 @@ class TestImage: assert im.fp is None -class MockEncoder: +class MockEncoder(ImageFile.PyEncoder): pass -def mock_encode(*args): - encoder = MockEncoder() - encoder.args = args - return encoder - - class TestRegistry: - def test_encode_registry(self): - Image.register_encoder("MOCK", mock_encode) + def test_encode_registry(self) -> None: + Image.register_encoder("MOCK", MockEncoder) assert "MOCK" in Image.ENCODERS enc = Image._getencoder("RGB", "MOCK", ("args",), extra=("extra",)) assert isinstance(enc, MockEncoder) - assert enc.args == ("RGB", "args", "extra") + assert enc.mode == "RGB" + assert enc.args == ("args", "extra") - def test_encode_registry_fail(self): + def test_encode_registry_fail(self) -> None: with pytest.raises(OSError): Image._getencoder("RGB", "DoesNotExist", ("args",), extra=("extra",)) diff --git a/Tests/test_image_access.py b/Tests/test_image_access.py index 4a794371d..8c42da57a 100644 --- a/Tests/test_image_access.py +++ b/Tests/test_image_access.py @@ -1,8 +1,10 @@ from __future__ import annotations + import os import subprocess import sys import sysconfig +from types import ModuleType import pytest @@ -12,6 +14,7 @@ from .helper import assert_image_equal, hopper, is_win32 # CFFI imports pycparser which doesn't support PYTHONOPTIMIZE=2 # https://github.com/eliben/pycparser/pull/198#issuecomment-317001670 +cffi: ModuleType | None if os.environ.get("PYTHONOPTIMIZE") == "2": cffi = None else: @@ -22,6 +25,7 @@ else: except ImportError: cffi = None +numpy: ModuleType | None try: import numpy except ImportError: @@ -34,16 +38,16 @@ class AccessTest: _need_cffi_access = False @classmethod - def setup_class(cls): + def setup_class(cls) -> None: Image.USE_CFFI_ACCESS = cls._need_cffi_access @classmethod - def teardown_class(cls): + def teardown_class(cls) -> None: Image.USE_CFFI_ACCESS = cls._init_cffi_access class TestImagePutPixel(AccessTest): - def test_sanity(self): + def test_sanity(self) -> None: im1 = hopper() im2 = Image.new(im1.mode, im1.size, 0) @@ -70,9 +74,10 @@ class TestImagePutPixel(AccessTest): pix1 = im1.load() pix2 = im2.load() - for x, y in ((0, "0"), ("0", 0)): - with pytest.raises(TypeError): - pix1[x, y] + with pytest.raises(TypeError): + pix1[0, "0"] + with pytest.raises(TypeError): + pix1["0", 0] for y in range(im1.size[1]): for x in range(im1.size[0]): @@ -80,7 +85,7 @@ class TestImagePutPixel(AccessTest): assert_image_equal(im1, im2) - def test_sanity_negative_index(self): + def test_sanity_negative_index(self) -> None: im1 = hopper() im2 = Image.new(im1.mode, im1.size, 0) @@ -118,16 +123,17 @@ class TestImagePutPixel(AccessTest): assert_image_equal(im1, im2) @pytest.mark.skipif(numpy is None, reason="NumPy not installed") - def test_numpy(self): + def test_numpy(self) -> None: im = hopper() pix = im.load() + assert numpy is not None assert pix[numpy.int32(1), numpy.int32(2)] == (18, 20, 59) class TestImageGetPixel(AccessTest): @staticmethod - def color(mode): + def color(mode: str) -> int | tuple[int, ...]: bands = Image.getmodebands(mode) if bands == 1: return 1 @@ -137,12 +143,13 @@ class TestImageGetPixel(AccessTest): return (16, 32, 49) return tuple(range(1, bands + 1)) - def check(self, mode, expected_color=None): + def check(self, mode: str, expected_color_int: int | None = None) -> None: if self._need_cffi_access and mode.startswith("BGR;"): pytest.skip("Support not added to deprecated module for BGR;* modes") - if not expected_color: - expected_color = self.color(mode) + expected_color = ( + self.color(mode) if expected_color_int is None else expected_color_int + ) # check putpixel im = Image.new(mode, (1, 1), None) @@ -221,25 +228,23 @@ class TestImageGetPixel(AccessTest): "YCbCr", ), ) - def test_basic(self, mode): + def test_basic(self, mode: str) -> None: self.check(mode) - def test_list(self): + def test_list(self) -> None: im = hopper() assert im.getpixel([0, 0]) == (20, 20, 70) @pytest.mark.parametrize("mode", ("I;16", "I;16B")) - @pytest.mark.parametrize( - "expected_color", (2**15 - 1, 2**15, 2**15 + 1, 2**16 - 1) - ) - def test_signedness(self, mode, expected_color): + @pytest.mark.parametrize("expected_color", (2**15 - 1, 2**15, 2**15 + 1, 2**16 - 1)) + def test_signedness(self, mode: str, expected_color: int) -> None: # see https://github.com/python-pillow/Pillow/issues/452 # pixelaccess is using signed int* instead of uint* self.check(mode, expected_color) @pytest.mark.parametrize("mode", ("P", "PA")) @pytest.mark.parametrize("color", ((255, 0, 0), (255, 0, 0, 255))) - def test_p_putpixel_rgb_rgba(self, mode, color): + def test_p_putpixel_rgb_rgba(self, mode: str, color: tuple[int, ...]) -> None: im = Image.new(mode, (1, 1)) im.putpixel((0, 0), color) @@ -263,7 +268,7 @@ class TestCffiGetPixel(TestImageGetPixel): class TestCffi(AccessTest): _need_cffi_access = True - def _test_get_access(self, im): + def _test_get_access(self, im: Image.Image) -> None: """Do we get the same thing as the old pixel access Using private interfaces, forcing a capi access and @@ -281,7 +286,7 @@ class TestCffi(AccessTest): with pytest.raises(ValueError): access[(access.xsize + 1, access.ysize + 1)] - def test_get_vs_c(self): + def test_get_vs_c(self) -> None: with pytest.warns(DeprecationWarning): rgb = hopper("RGB") rgb.load() @@ -300,7 +305,7 @@ class TestCffi(AccessTest): # im = Image.new('I;32B', (10, 10), 2**10) # self._test_get_access(im) - def _test_set_access(self, im, color): + def _test_set_access(self, im: Image.Image, color: tuple[int, ...] | float) -> None: """Are we writing the correct bits into the image? Using private interfaces, forcing a capi access and @@ -321,7 +326,7 @@ class TestCffi(AccessTest): with pytest.raises(ValueError): access[(0, 0)] = color - def test_set_vs_c(self): + def test_set_vs_c(self) -> None: rgb = hopper("RGB") with pytest.warns(DeprecationWarning): rgb.load() @@ -344,11 +349,11 @@ class TestCffi(AccessTest): # self._test_set_access(im, 2**13-1) @pytest.mark.filterwarnings("ignore::DeprecationWarning") - def test_not_implemented(self): + def test_not_implemented(self) -> None: assert PyAccess.new(hopper("BGR;15")) is None # ref https://github.com/python-pillow/Pillow/pull/2009 - def test_reference_counting(self): + def test_reference_counting(self) -> None: size = 10 for _ in range(10): @@ -360,7 +365,7 @@ class TestCffi(AccessTest): assert px[i, 0] == 0 @pytest.mark.parametrize("mode", ("P", "PA")) - def test_p_putpixel_rgb_rgba(self, mode): + def test_p_putpixel_rgb_rgba(self, mode: str) -> None: for color in ((255, 0, 0), (255, 0, 0, 127 if mode == "PA" else 255)): im = Image.new(mode, (1, 1)) with pytest.warns(DeprecationWarning): @@ -378,7 +383,7 @@ class TestImagePutPixelError(AccessTest): INVALID_TYPES = ["foo", 1.0, None] @pytest.mark.parametrize("mode", IMAGE_MODES1) - def test_putpixel_type_error1(self, mode): + def test_putpixel_type_error1(self, mode: str) -> None: im = hopper(mode) for v in self.INVALID_TYPES: with pytest.raises(TypeError, match="color must be int or tuple"): @@ -401,14 +406,16 @@ class TestImagePutPixelError(AccessTest): ), ), ) - def test_putpixel_invalid_number_of_bands(self, mode, band_numbers, match): + def test_putpixel_invalid_number_of_bands( + self, mode: str, band_numbers: tuple[int, ...], match: str + ) -> None: im = hopper(mode) for band_number in band_numbers: with pytest.raises(TypeError, match=match): im.putpixel((0, 0), (0,) * band_number) @pytest.mark.parametrize("mode", IMAGE_MODES2) - def test_putpixel_type_error2(self, mode): + def test_putpixel_type_error2(self, mode: str) -> None: im = hopper(mode) for v in self.INVALID_TYPES: with pytest.raises( @@ -417,7 +424,7 @@ class TestImagePutPixelError(AccessTest): im.putpixel((0, 0), v) @pytest.mark.parametrize("mode", IMAGE_MODES1 + IMAGE_MODES2) - def test_putpixel_overflow_error(self, mode): + def test_putpixel_overflow_error(self, mode: str) -> None: im = hopper(mode) with pytest.raises(OverflowError): im.putpixel((0, 0), 2**80) @@ -426,10 +433,10 @@ class TestImagePutPixelError(AccessTest): class TestEmbeddable: @pytest.mark.xfail(reason="failing test") @pytest.mark.skipif(not is_win32(), reason="requires Windows") - def test_embeddable(self): + def test_embeddable(self) -> None: import ctypes - from setuptools.command.build_ext import new_compiler + from setuptools.command import build_ext with open("embed_pil.c", "w", encoding="utf-8") as fh: fh.write( @@ -458,7 +465,7 @@ int main(int argc, char* argv[]) % sys.prefix.replace("\\", "\\\\") ) - compiler = new_compiler() + compiler = getattr(build_ext, "new_compiler")() compiler.add_include_dir(sysconfig.get_config_var("INCLUDEPY")) libdir = sysconfig.get_config_var("LIBDIR") or sysconfig.get_config_var( @@ -472,7 +479,7 @@ int main(int argc, char* argv[]) env["PATH"] = sys.prefix + ";" + env["PATH"] # do not display the Windows Error Reporting dialog - ctypes.windll.kernel32.SetErrorMode(0x0002) + getattr(ctypes, "windll").kernel32.SetErrorMode(0x0002) process = subprocess.Popen(["embed_pil.exe"], env=env) process.communicate() diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index b3e5d9e3e..cf85ee4fa 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from typing import Any + import pytest from packaging.version import parse as parse_version @@ -11,12 +14,12 @@ numpy = pytest.importorskip("numpy", reason="NumPy not installed") im = hopper().resize((128, 100)) -def test_toarray(): - def test(mode): +def test_toarray() -> None: + def test(mode: str) -> tuple[tuple[int, ...], str, int]: ai = numpy.array(im.convert(mode)) return ai.shape, ai.dtype.str, ai.nbytes - def test_with_dtype(dtype): + def test_with_dtype(dtype) -> None: ai = numpy.array(im, dtype=dtype) assert ai.dtype == dtype @@ -45,18 +48,18 @@ def test_toarray(): numpy.array(im_truncated) -def test_fromarray(): +def test_fromarray() -> None: class Wrapper: """Class with API matching Image.fromarray""" - def __init__(self, img, arr_params): + def __init__(self, img: Image.Image, arr_params: dict[str, Any]) -> None: self.img = img self.__array_interface__ = arr_params - def tobytes(self): + def tobytes(self) -> bytes: return self.img.tobytes() - def test(mode): + def test(mode: str) -> tuple[str, tuple[int, int], bool]: i = im.convert(mode) a = numpy.array(i) # Make wrapper instance for image, new array interface @@ -88,7 +91,7 @@ def test_fromarray(): Image.fromarray(wrapped) -def test_fromarray_palette(): +def test_fromarray_palette() -> None: # Arrange i = im.convert("L") a = numpy.array(i) diff --git a/Tests/test_image_convert.py b/Tests/test_image_convert.py index 7c17040d3..f154de123 100644 --- a/Tests/test_image_convert.py +++ b/Tests/test_image_convert.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import Image @@ -6,8 +9,8 @@ from PIL import Image from .helper import assert_image, assert_image_equal, assert_image_similar, hopper -def test_sanity(): - def convert(im, mode): +def test_sanity() -> None: + def convert(im: Image.Image, mode: str) -> None: out = im.convert(mode) assert out.mode == mode assert out.size == im.size @@ -39,13 +42,13 @@ def test_sanity(): convert(im, output_mode) -def test_unsupported_conversion(): +def test_unsupported_conversion() -> None: im = hopper() with pytest.raises(ValueError): im.convert("INVALID") -def test_default(): +def test_default() -> None: im = hopper("P") assert im.mode == "P" converted_im = im.convert() @@ -61,18 +64,18 @@ def test_default(): # ref https://github.com/python-pillow/Pillow/issues/274 -def _test_float_conversion(im): +def _test_float_conversion(im: Image.Image) -> None: orig = im.getpixel((5, 5)) converted = im.convert("F").getpixel((5, 5)) assert orig == converted -def test_8bit(): +def test_8bit() -> None: with Image.open("Tests/images/hopper.jpg") as im: _test_float_conversion(im.convert("L")) -def test_16bit(): +def test_16bit() -> None: with Image.open("Tests/images/16bit.cropped.tif") as im: _test_float_conversion(im) @@ -82,19 +85,19 @@ def test_16bit(): assert im_i16.getpixel((0, 0)) == 65535 -def test_16bit_workaround(): +def test_16bit_workaround() -> None: with Image.open("Tests/images/16bit.cropped.tif") as im: _test_float_conversion(im.convert("I")) -def test_opaque(): +def test_opaque() -> None: alpha = hopper("P").convert("PA").getchannel("A") solid = Image.new("L", (128, 128), 255) assert_image_equal(alpha, solid) -def test_rgba_p(): +def test_rgba_p() -> None: im = hopper("RGBA") im.putalpha(hopper("L")) @@ -104,14 +107,14 @@ def test_rgba_p(): assert_image_similar(im, comparable, 20) -def test_rgba(): +def test_rgba() -> None: with Image.open("Tests/images/transparent.png") as im: assert im.mode == "RGBA" assert_image_similar(im.convert("RGBa").convert("RGB"), im.convert("RGB"), 1.5) -def test_trns_p(tmp_path): +def test_trns_p(tmp_path: Path) -> None: im = hopper("P") im.info["transparency"] = 0 @@ -130,7 +133,7 @@ def test_trns_p(tmp_path): @pytest.mark.parametrize("mode", ("LA", "PA", "RGBA")) -def test_trns_p_transparency(mode): +def test_trns_p_transparency(mode: str) -> None: # Arrange im = hopper("P") im.info["transparency"] = 128 @@ -147,7 +150,7 @@ def test_trns_p_transparency(mode): assert converted_im.palette is None -def test_trns_l(tmp_path): +def test_trns_l(tmp_path: Path) -> None: im = hopper("L") im.info["transparency"] = 128 @@ -170,7 +173,7 @@ def test_trns_l(tmp_path): im_p.save(f) -def test_trns_RGB(tmp_path): +def test_trns_RGB(tmp_path: Path) -> None: im = hopper("RGB") im.info["transparency"] = im.getpixel((0, 0)) @@ -200,7 +203,7 @@ def test_trns_RGB(tmp_path): @pytest.mark.parametrize("convert_mode", ("L", "LA", "I")) -def test_l_macro_rounding(convert_mode): +def test_l_macro_rounding(convert_mode: str) -> None: for mode in ("P", "PA"): im = Image.new(mode, (1, 1)) im.palette.getcolor((0, 1, 2)) @@ -213,7 +216,7 @@ def test_l_macro_rounding(convert_mode): assert converted_color == 1 -def test_gif_with_rgba_palette_to_p(): +def test_gif_with_rgba_palette_to_p() -> None: # See https://github.com/python-pillow/Pillow/issues/2433 with Image.open("Tests/images/hopper.gif") as im: im.info["transparency"] = 255 @@ -225,7 +228,7 @@ def test_gif_with_rgba_palette_to_p(): im_p.load() -def test_p_la(): +def test_p_la() -> None: im = hopper("RGBA") alpha = hopper("L") im.putalpha(alpha) @@ -235,7 +238,7 @@ def test_p_la(): assert_image_similar(alpha, comparable, 5) -def test_p2pa_alpha(): +def test_p2pa_alpha() -> None: with Image.open("Tests/images/tiny.png") as im: assert im.mode == "P" @@ -249,13 +252,13 @@ def test_p2pa_alpha(): assert im_a.getpixel((x, y)) == alpha -def test_p2pa_palette(): +def test_p2pa_palette() -> None: with Image.open("Tests/images/tiny.png") as im: im_pa = im.convert("PA") assert im_pa.getpalette() == im.getpalette() -def test_matrix_illegal_conversion(): +def test_matrix_illegal_conversion() -> None: # Arrange im = hopper("CMYK") # fmt: off @@ -271,7 +274,7 @@ def test_matrix_illegal_conversion(): im.convert(mode="CMYK", matrix=matrix) -def test_matrix_wrong_mode(): +def test_matrix_wrong_mode() -> None: # Arrange im = hopper("L") # fmt: off @@ -288,7 +291,7 @@ def test_matrix_wrong_mode(): @pytest.mark.parametrize("mode", ("RGB", "L")) -def test_matrix_xyz(mode): +def test_matrix_xyz(mode: str) -> None: # Arrange im = hopper("RGB") im.info["transparency"] = (255, 0, 0) @@ -316,7 +319,7 @@ def test_matrix_xyz(mode): assert converted_im.info["transparency"] == 105 -def test_matrix_identity(): +def test_matrix_identity() -> None: # Arrange im = hopper("RGB") # fmt: off diff --git a/Tests/test_image_copy.py b/Tests/test_image_copy.py index abf5f846f..027e5338b 100644 --- a/Tests/test_image_copy.py +++ b/Tests/test_image_copy.py @@ -1,4 +1,5 @@ from __future__ import annotations + import copy import pytest @@ -9,7 +10,7 @@ from .helper import hopper, skip_unless_feature @pytest.mark.parametrize("mode", ("1", "P", "L", "RGB", "I", "F")) -def test_copy(mode): +def test_copy(mode: str) -> None: cropped_coordinates = (10, 10, 20, 20) cropped_size = (10, 10) @@ -38,7 +39,7 @@ def test_copy(mode): assert out.size == cropped_size -def test_copy_zero(): +def test_copy_zero() -> None: im = Image.new("RGB", (0, 0)) out = im.copy() assert out.mode == im.mode @@ -46,7 +47,7 @@ def test_copy_zero(): @skip_unless_feature("libtiff") -def test_deepcopy(): +def test_deepcopy() -> None: with Image.open("Tests/images/g4_orientation_5.tif") as im: out = copy.deepcopy(im) assert out.size == (590, 88) diff --git a/Tests/test_image_crop.py b/Tests/test_image_crop.py index 0bb54e5d8..d095364ba 100644 --- a/Tests/test_image_crop.py +++ b/Tests/test_image_crop.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image @@ -7,7 +8,7 @@ from .helper import assert_image_equal, hopper @pytest.mark.parametrize("mode", ("1", "P", "L", "RGB", "I", "F")) -def test_crop(mode): +def test_crop(mode: str) -> None: im = hopper(mode) assert_image_equal(im.crop(), im) @@ -16,8 +17,8 @@ def test_crop(mode): assert cropped.size == (50, 50) -def test_wide_crop(): - def crop(*bbox): +def test_wide_crop() -> None: + def crop(*bbox: int) -> tuple[int, ...]: i = im.crop(bbox) h = i.histogram() while h and not h[-1]: @@ -46,14 +47,14 @@ def test_wide_crop(): @pytest.mark.parametrize("box", ((8, 2, 2, 8), (2, 8, 8, 2), (8, 8, 2, 2))) -def test_negative_crop(box): +def test_negative_crop(box: tuple[int, int, int, int]) -> None: im = Image.new("RGB", (10, 10)) with pytest.raises(ValueError): im.crop(box) -def test_crop_float(): +def test_crop_float() -> None: # Check cropping floats are rounded to nearest integer # https://github.com/python-pillow/Pillow/issues/1744 @@ -68,7 +69,7 @@ def test_crop_float(): assert cropped.size == (3, 5) -def test_crop_crash(): +def test_crop_crash() -> None: # Image.crop crashes prepatch with an access violation # apparently a use after free on Windows, see # https://github.com/python-pillow/Pillow/issues/1077 @@ -86,7 +87,7 @@ def test_crop_crash(): img.load() -def test_crop_zero(): +def test_crop_zero() -> None: im = Image.new("RGB", (0, 0), "white") cropped = im.crop((0, 0, 0, 0)) diff --git a/Tests/test_image_draft.py b/Tests/test_image_draft.py index 774272dd1..1ce1a7cd8 100644 --- a/Tests/test_image_draft.py +++ b/Tests/test_image_draft.py @@ -1,4 +1,5 @@ from __future__ import annotations + from PIL import Image from .helper import fromstring, skip_unless_feature, tostring @@ -6,7 +7,12 @@ from .helper import fromstring, skip_unless_feature, tostring pytestmark = skip_unless_feature("jpg") -def draft_roundtrip(in_mode, in_size, req_mode, req_size): +def draft_roundtrip( + in_mode: str, + in_size: tuple[int, int], + req_mode: str | None, + req_size: tuple[int, int] | None, +) -> Image.Image: im = Image.new(in_mode, in_size) data = tostring(im, "JPEG") im = fromstring(data) @@ -18,7 +24,7 @@ def draft_roundtrip(in_mode, in_size, req_mode, req_size): return im -def test_size(): +def test_size() -> None: for in_size, req_size, out_size in [ ((435, 361), (2048, 2048), (435, 361)), # bigger ((435, 361), (435, 361), (435, 361)), # same @@ -47,7 +53,7 @@ def test_size(): assert im.size == out_size -def test_mode(): +def test_mode() -> None: for in_mode, req_mode, out_mode in [ ("RGB", "1", "RGB"), ("RGB", "L", "L"), @@ -67,7 +73,7 @@ def test_mode(): assert im.mode == out_mode -def test_several_drafts(): +def test_several_drafts() -> None: im = draft_roundtrip("L", (128, 128), None, (64, 64)) im.draft(None, (64, 64)) im.load() diff --git a/Tests/test_image_entropy.py b/Tests/test_image_entropy.py index 031fceda3..c1dbb879b 100644 --- a/Tests/test_image_entropy.py +++ b/Tests/test_image_entropy.py @@ -1,9 +1,10 @@ from __future__ import annotations + from .helper import hopper -def test_entropy(): - def entropy(mode): +def test_entropy() -> None: + def entropy(mode: str) -> float: return hopper(mode).entropy() assert round(abs(entropy("1") - 0.9138803254693582), 7) == 0 diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py index 5bd7ee0d2..47f9ffa3d 100644 --- a/Tests/test_image_filter.py +++ b/Tests/test_image_filter.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, ImageFilter @@ -35,7 +36,7 @@ from .helper import assert_image_equal, hopper ), ) @pytest.mark.parametrize("mode", ("L", "I", "RGB", "CMYK")) -def test_sanity(filter_to_apply, mode): +def test_sanity(filter_to_apply: ImageFilter.Filter, mode: str) -> None: im = hopper(mode) if mode != "I" or isinstance(filter_to_apply, ImageFilter.BuiltinFilter): out = im.filter(filter_to_apply) @@ -44,7 +45,7 @@ def test_sanity(filter_to_apply, mode): @pytest.mark.parametrize("mode", ("L", "I", "RGB", "CMYK")) -def test_sanity_error(mode): +def test_sanity_error(mode: str) -> None: with pytest.raises(TypeError): im = hopper(mode) im.filter("hello") @@ -52,7 +53,7 @@ def test_sanity_error(mode): # crashes on small images @pytest.mark.parametrize("size", ((1, 1), (2, 2), (3, 3))) -def test_crash(size): +def test_crash(size: tuple[int, int]) -> None: im = Image.new("RGB", size) im.filter(ImageFilter.SMOOTH) @@ -66,7 +67,10 @@ def test_crash(size): ("RGB", ((4, 0, 0), (0, 0, 0))), ), ) -def test_modefilter(mode, expected): +def test_modefilter( + mode: str, + expected: tuple[int, int] | tuple[tuple[int, int, int], tuple[int, int, int]], +) -> None: im = Image.new(mode, (3, 3), None) im.putdata(list(range(9))) # image is: @@ -89,7 +93,13 @@ def test_modefilter(mode, expected): ("F", (0.0, 4.0, 8.0)), ), ) -def test_rankfilter(mode, expected): +def test_rankfilter( + mode: str, + expected: ( + tuple[float, float, float] + | tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]] + ), +) -> None: im = Image.new(mode, (3, 3), None) im.putdata(list(range(9))) # image is: @@ -105,7 +115,7 @@ def test_rankfilter(mode, expected): @pytest.mark.parametrize( "filter", (ImageFilter.MinFilter, ImageFilter.MedianFilter, ImageFilter.MaxFilter) ) -def test_rankfilter_error(filter): +def test_rankfilter_error(filter: ImageFilter.RankFilter) -> None: with pytest.raises(ValueError): im = Image.new("P", (3, 3), None) im.putdata(list(range(9))) @@ -116,31 +126,29 @@ def test_rankfilter_error(filter): im.filter(filter).getpixel((1, 1)) -def test_rankfilter_properties(): +def test_rankfilter_properties() -> None: rankfilter = ImageFilter.RankFilter(1, 2) assert rankfilter.size == 1 assert rankfilter.rank == 2 -def test_builtinfilter_p(): +def test_builtinfilter_p() -> None: builtin_filter = ImageFilter.BuiltinFilter() with pytest.raises(ValueError): builtin_filter.filter(hopper("P")) -def test_kernel_not_enough_coefficients(): +def test_kernel_not_enough_coefficients() -> None: with pytest.raises(ValueError): ImageFilter.Kernel((3, 3), (0, 0)) @pytest.mark.parametrize("mode", ("L", "LA", "I", "RGB", "CMYK")) -def test_consistency_3x3(mode): +def test_consistency_3x3(mode: str) -> None: with Image.open("Tests/images/hopper.bmp") as source: - reference_name = "hopper_emboss" - reference_name += "_I.png" if mode == "I" else ".bmp" - with Image.open("Tests/images/" + reference_name) as reference: + with Image.open("Tests/images/hopper_emboss.bmp") as reference: kernel = ImageFilter.Kernel( (3, 3), # fmt: off @@ -150,23 +158,13 @@ def test_consistency_3x3(mode): # fmt: on 0.3, ) - source = source.split() * 2 - reference = reference.split() * 2 - - if mode == "I": - source = source[0].convert(mode) - else: - source = Image.merge(mode, source[: len(mode)]) - reference = Image.merge(mode, reference[: len(mode)]) assert_image_equal(source.filter(kernel), reference) @pytest.mark.parametrize("mode", ("L", "LA", "I", "RGB", "CMYK")) -def test_consistency_5x5(mode): +def test_consistency_5x5(mode: str) -> None: with Image.open("Tests/images/hopper.bmp") as source: - reference_name = "hopper_emboss_more" - reference_name += "_I.png" if mode == "I" else ".bmp" - with Image.open("Tests/images/" + reference_name) as reference: + with Image.open("Tests/images/hopper_emboss_more.bmp") as reference: kernel = ImageFilter.Kernel( (5, 5), # fmt: off @@ -178,14 +176,6 @@ def test_consistency_5x5(mode): # fmt: on 0.3, ) - source = source.split() * 2 - reference = reference.split() * 2 - - if mode == "I": - source = source[0].convert(mode) - else: - source = Image.merge(mode, source[: len(mode)]) - reference = Image.merge(mode, reference[: len(mode)]) assert_image_equal(source.filter(kernel), reference) @@ -198,7 +188,7 @@ def test_consistency_5x5(mode): (2, -2), ), ) -def test_invalid_box_blur_filter(radius): +def test_invalid_box_blur_filter(radius: int | tuple[int, int]) -> None: with pytest.raises(ValueError): ImageFilter.BoxBlur(radius) diff --git a/Tests/test_image_frombytes.py b/Tests/test_image_frombytes.py index 017da499d..98c0ea0b4 100644 --- a/Tests/test_image_frombytes.py +++ b/Tests/test_image_frombytes.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image @@ -7,7 +8,7 @@ from .helper import assert_image_equal, hopper @pytest.mark.parametrize("data_type", ("bytes", "memoryview")) -def test_sanity(data_type): +def test_sanity(data_type: str) -> None: im1 = hopper() data = im1.tobytes() diff --git a/Tests/test_image_fromqimage.py b/Tests/test_image_fromqimage.py index b3ca43bde..c20123a1b 100644 --- a/Tests/test_image_fromqimage.py +++ b/Tests/test_image_fromqimage.py @@ -1,4 +1,5 @@ from __future__ import annotations + import warnings import pytest @@ -15,22 +16,19 @@ pytestmark = pytest.mark.skipif( not ImageQt.qt_is_installed, reason="Qt bindings are not installed" ) - -@pytest.fixture -def test_images(): - ims = [ - hopper(), - Image.open("Tests/images/transparent.png"), - Image.open("Tests/images/7x13.png"), - ] - try: - yield ims - finally: - for im in ims: - im.close() +ims = [ + hopper(), + Image.open("Tests/images/transparent.png"), + Image.open("Tests/images/7x13.png"), +] -def roundtrip(expected): +def teardown_module() -> None: + for im in ims: + im.close() + + +def roundtrip(expected: Image.Image) -> None: # PIL -> Qt intermediate = expected.toqimage() # Qt -> PIL @@ -42,26 +40,26 @@ def roundtrip(expected): assert_image_equal(result, expected.convert("RGB")) -def test_sanity_1(test_images): - for im in test_images: +def test_sanity_1() -> None: + for im in ims: roundtrip(im.convert("1")) -def test_sanity_rgb(test_images): - for im in test_images: +def test_sanity_rgb() -> None: + for im in ims: roundtrip(im.convert("RGB")) -def test_sanity_rgba(test_images): - for im in test_images: +def test_sanity_rgba() -> None: + for im in ims: roundtrip(im.convert("RGBA")) -def test_sanity_l(test_images): - for im in test_images: +def test_sanity_l() -> None: + for im in ims: roundtrip(im.convert("L")) -def test_sanity_p(test_images): - for im in test_images: +def test_sanity_p() -> None: + for im in ims: roundtrip(im.convert("P")) diff --git a/Tests/test_image_getbands.py b/Tests/test_image_getbands.py index e7701dbc4..887553fc0 100644 --- a/Tests/test_image_getbands.py +++ b/Tests/test_image_getbands.py @@ -1,8 +1,9 @@ from __future__ import annotations + from PIL import Image -def test_getbands(): +def test_getbands() -> None: assert Image.new("1", (1, 1)).getbands() == ("1",) assert Image.new("L", (1, 1)).getbands() == ("L",) assert Image.new("I", (1, 1)).getbands() == ("I",) diff --git a/Tests/test_image_getbbox.py b/Tests/test_image_getbbox.py index 9e792cfdf..18c6f6579 100644 --- a/Tests/test_image_getbbox.py +++ b/Tests/test_image_getbbox.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image @@ -6,13 +7,13 @@ from PIL import Image from .helper import hopper -def test_sanity(): +def test_sanity() -> None: bbox = hopper().getbbox() assert isinstance(bbox, tuple) -def test_bbox(): - def check(im, fill_color): +def test_bbox() -> None: + def check(im: Image.Image, fill_color: int | tuple[int, ...]) -> None: assert im.getbbox() is None im.paste(fill_color, (10, 25, 90, 75)) @@ -33,8 +34,8 @@ def test_bbox(): check(im, 255) for mode in ("RGBA", "RGBa"): - for color in ((0, 0, 0, 0), (127, 127, 127, 0), (255, 255, 255, 0)): - im = Image.new(mode, (100, 100), color) + for rgba_color in ((0, 0, 0, 0), (127, 127, 127, 0), (255, 255, 255, 0)): + im = Image.new(mode, (100, 100), rgba_color) check(im, (255, 255, 255, 255)) for mode in ("La", "LA", "PA"): @@ -44,7 +45,7 @@ def test_bbox(): @pytest.mark.parametrize("mode", ("RGBA", "RGBa", "La", "LA", "PA")) -def test_bbox_alpha_only_false(mode): +def test_bbox_alpha_only_false(mode: str) -> None: im = Image.new(mode, (100, 100)) assert im.getbbox(alpha_only=False) is None diff --git a/Tests/test_image_getcolors.py b/Tests/test_image_getcolors.py index dea3a60a1..8f8870f4f 100644 --- a/Tests/test_image_getcolors.py +++ b/Tests/test_image_getcolors.py @@ -1,9 +1,10 @@ from __future__ import annotations + from .helper import hopper -def test_getcolors(): - def getcolors(mode, limit=None): +def test_getcolors() -> None: + def getcolors(mode: str, limit: int | None = None) -> int | None: im = hopper(mode) if limit: colors = im.getcolors(limit) @@ -38,7 +39,7 @@ def test_getcolors(): # -------------------------------------------------------------------- -def test_pack(): +def test_pack() -> None: # Pack problems for small tables (@PIL209) im = hopper().quantize(3).convert("RGB") diff --git a/Tests/test_image_getdata.py b/Tests/test_image_getdata.py index 873cc65bf..ac27400be 100644 --- a/Tests/test_image_getdata.py +++ b/Tests/test_image_getdata.py @@ -1,10 +1,11 @@ from __future__ import annotations + from PIL import Image from .helper import hopper -def test_sanity(): +def test_sanity() -> None: data = hopper().getdata() len(data) @@ -13,8 +14,8 @@ def test_sanity(): assert data[0] == (20, 20, 70) -def test_roundtrip(): - def getdata(mode): +def test_roundtrip() -> None: + def getdata(mode: str) -> tuple[float | tuple[int, ...], int, int]: im = hopper(mode).resize((32, 30), Image.Resampling.NEAREST) data = im.getdata() return data[0], len(data), len(list(data)) diff --git a/Tests/test_image_getextrema.py b/Tests/test_image_getextrema.py index b17c8a786..a5b974459 100644 --- a/Tests/test_image_getextrema.py +++ b/Tests/test_image_getextrema.py @@ -1,11 +1,12 @@ from __future__ import annotations + from PIL import Image from .helper import hopper -def test_extrema(): - def extrema(mode): +def test_extrema() -> None: + def extrema(mode: str) -> tuple[int, int] | tuple[tuple[int, int], ...]: return hopper(mode).getextrema() assert extrema("1") == (0, 255) @@ -19,7 +20,7 @@ def test_extrema(): assert extrema("I;16") == (1, 255) -def test_true_16(): +def test_true_16() -> None: with Image.open("Tests/images/16_bit_noise.tif") as im: assert im.mode == "I;16" extrema = im.getextrema() diff --git a/Tests/test_image_getim.py b/Tests/test_image_getim.py index e969c8164..9afa02b0a 100644 --- a/Tests/test_image_getim.py +++ b/Tests/test_image_getim.py @@ -1,8 +1,9 @@ from __future__ import annotations + from .helper import hopper -def test_sanity(): +def test_sanity() -> None: im = hopper() type_repr = repr(type(im.getim())) diff --git a/Tests/test_image_getpalette.py b/Tests/test_image_getpalette.py index a5be972d3..6a8f157fc 100644 --- a/Tests/test_image_getpalette.py +++ b/Tests/test_image_getpalette.py @@ -1,11 +1,12 @@ from __future__ import annotations + from PIL import Image from .helper import hopper -def test_palette(): - def palette(mode): +def test_palette() -> None: + def palette(mode: str) -> list[int] | None: p = hopper(mode).getpalette() if p: return p[:10] @@ -22,7 +23,7 @@ def test_palette(): assert palette("YCbCr") is None -def test_palette_rawmode(): +def test_palette_rawmode() -> None: im = Image.new("P", (1, 1)) im.putpalette((1, 2, 3)) diff --git a/Tests/test_image_getprojection.py b/Tests/test_image_getprojection.py index aa47be3b2..2b5a758ed 100644 --- a/Tests/test_image_getprojection.py +++ b/Tests/test_image_getprojection.py @@ -1,10 +1,11 @@ from __future__ import annotations + from PIL import Image from .helper import hopper -def test_sanity(): +def test_sanity() -> None: im = hopper() projection = im.getprojection() diff --git a/Tests/test_image_histogram.py b/Tests/test_image_histogram.py index 7ba2f10b7..dbd55d4c2 100644 --- a/Tests/test_image_histogram.py +++ b/Tests/test_image_histogram.py @@ -1,9 +1,10 @@ from __future__ import annotations + from .helper import hopper -def test_histogram(): - def histogram(mode): +def test_histogram() -> None: + def histogram(mode: str) -> tuple[int, int, int]: h = hopper(mode).histogram() return len(h), min(h), max(h) diff --git a/Tests/test_image_load.py b/Tests/test_image_load.py index 17847c4fd..0605821e0 100644 --- a/Tests/test_image_load.py +++ b/Tests/test_image_load.py @@ -1,4 +1,5 @@ from __future__ import annotations + import logging import os @@ -9,14 +10,14 @@ from PIL import Image from .helper import hopper -def test_sanity(): +def test_sanity() -> None: im = hopper() pix = im.load() assert pix[0, 0] == (20, 20, 70) -def test_close(): +def test_close() -> None: im = Image.open("Tests/images/hopper.gif") im.close() with pytest.raises(ValueError): @@ -25,7 +26,7 @@ def test_close(): im.getpixel((0, 0)) -def test_close_after_load(caplog): +def test_close_after_load(caplog: pytest.LogCaptureFixture) -> None: im = Image.open("Tests/images/hopper.gif") im.load() with caplog.at_level(logging.DEBUG): @@ -33,7 +34,7 @@ def test_close_after_load(caplog): assert len(caplog.records) == 0 -def test_contextmanager(): +def test_contextmanager() -> None: fn = None with Image.open("Tests/images/hopper.gif") as im: fn = im.fp.fileno() @@ -43,7 +44,7 @@ def test_contextmanager(): os.fstat(fn) -def test_contextmanager_non_exclusive_fp(): +def test_contextmanager_non_exclusive_fp() -> None: with open("Tests/images/hopper.gif", "rb") as fp: with Image.open(fp): pass diff --git a/Tests/test_image_mode.py b/Tests/test_image_mode.py index ad90d1250..8e94aafc5 100644 --- a/Tests/test_image_mode.py +++ b/Tests/test_image_mode.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, ImageMode @@ -6,7 +7,7 @@ from PIL import Image, ImageMode from .helper import hopper -def test_sanity(): +def test_sanity() -> None: with hopper() as im: im.mode @@ -68,7 +69,7 @@ def test_sanity(): ) def test_properties( mode, expected_base, expected_type, expected_bands, expected_band_names -): +) -> None: assert Image.getmodebase(mode) == expected_base assert Image.getmodetype(mode) == expected_type assert Image.getmodebands(mode) == expected_bands diff --git a/Tests/test_image_paste.py b/Tests/test_image_paste.py index 0b87f6072..d8f6b65e0 100644 --- a/Tests/test_image_paste.py +++ b/Tests/test_image_paste.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image @@ -7,13 +8,11 @@ from .helper import CachedProperty, assert_image_equal class TestImagingPaste: - masks = {} size = 128 - def assert_9points_image(self, im, expected): - expected = [ - point[0] if im.mode == "L" else point[: len(im.mode)] for point in expected - ] + def assert_9points_image( + self, im: Image.Image, expected: list[tuple[int, int, int, int]] + ) -> None: px = im.load() actual = [ px[0, 0], @@ -26,9 +25,17 @@ class TestImagingPaste: px[self.size // 2, self.size - 1], px[self.size - 1, self.size - 1], ] - assert actual == expected + assert actual == [ + point[0] if im.mode == "L" else point[: len(im.mode)] for point in expected + ] - def assert_9points_paste(self, im, im2, mask, expected): + def assert_9points_paste( + self, + im: Image.Image, + im2: Image.Image | str | tuple[int, ...], + mask: Image.Image, + expected: list[tuple[int, int, int, int]], + ) -> None: im3 = im.copy() im3.paste(im2, (0, 0), mask) self.assert_9points_image(im3, expected) @@ -38,7 +45,7 @@ class TestImagingPaste: self.assert_9points_image(im, expected) @CachedProperty - def mask_1(self): + def mask_1(self) -> Image.Image: mask = Image.new("1", (self.size, self.size)) px = mask.load() for y in range(mask.height): @@ -47,11 +54,11 @@ class TestImagingPaste: return mask @CachedProperty - def mask_L(self): + def mask_L(self) -> Image.Image: return self.gradient_L.transpose(Image.Transpose.ROTATE_270) @CachedProperty - def gradient_L(self): + def gradient_L(self) -> Image.Image: gradient = Image.new("L", (self.size, self.size)) px = gradient.load() for y in range(gradient.height): @@ -60,7 +67,7 @@ class TestImagingPaste: return gradient @CachedProperty - def gradient_RGB(self): + def gradient_RGB(self) -> Image.Image: return Image.merge( "RGB", [ @@ -71,7 +78,7 @@ class TestImagingPaste: ) @CachedProperty - def gradient_LA(self): + def gradient_LA(self) -> Image.Image: return Image.merge( "LA", [ @@ -81,7 +88,7 @@ class TestImagingPaste: ) @CachedProperty - def gradient_RGBA(self): + def gradient_RGBA(self) -> Image.Image: return Image.merge( "RGBA", [ @@ -93,7 +100,7 @@ class TestImagingPaste: ) @CachedProperty - def gradient_RGBa(self): + def gradient_RGBa(self) -> Image.Image: return Image.merge( "RGBa", [ @@ -105,7 +112,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_image_solid(self, mode): + def test_image_solid(self, mode: str) -> None: im = Image.new(mode, (200, 200), "red") im2 = getattr(self, "gradient_" + mode) @@ -115,7 +122,7 @@ class TestImagingPaste: assert_image_equal(im, im2) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_image_mask_1(self, mode): + def test_image_mask_1(self, mode: str) -> None: im = Image.new(mode, (200, 200), "white") im2 = getattr(self, "gradient_" + mode) @@ -137,7 +144,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_image_mask_L(self, mode): + def test_image_mask_L(self, mode: str) -> None: im = Image.new(mode, (200, 200), "white") im2 = getattr(self, "gradient_" + mode) @@ -159,7 +166,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_image_mask_LA(self, mode): + def test_image_mask_LA(self, mode: str) -> None: im = Image.new(mode, (200, 200), "white") im2 = getattr(self, "gradient_" + mode) @@ -181,7 +188,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_image_mask_RGBA(self, mode): + def test_image_mask_RGBA(self, mode: str) -> None: im = Image.new(mode, (200, 200), "white") im2 = getattr(self, "gradient_" + mode) @@ -203,7 +210,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_image_mask_RGBa(self, mode): + def test_image_mask_RGBa(self, mode: str) -> None: im = Image.new(mode, (200, 200), "white") im2 = getattr(self, "gradient_" + mode) @@ -225,7 +232,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_color_solid(self, mode): + def test_color_solid(self, mode: str) -> None: im = Image.new(mode, (200, 200), "black") rect = (12, 23, 128 + 12, 128 + 23) @@ -238,7 +245,7 @@ class TestImagingPaste: assert sum(head[:255]) == 0 @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_color_mask_1(self, mode): + def test_color_mask_1(self, mode: str) -> None: im = Image.new(mode, (200, 200), (50, 60, 70, 80)[: len(mode)]) color = (10, 20, 30, 40)[: len(mode)] @@ -260,7 +267,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_color_mask_L(self, mode): + def test_color_mask_L(self, mode: str) -> None: im = getattr(self, "gradient_" + mode).copy() color = "white" @@ -282,7 +289,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_color_mask_RGBA(self, mode): + def test_color_mask_RGBA(self, mode: str) -> None: im = getattr(self, "gradient_" + mode).copy() color = "white" @@ -304,7 +311,7 @@ class TestImagingPaste: ) @pytest.mark.parametrize("mode", ["RGBA", "RGB", "L"]) - def test_color_mask_RGBa(self, mode): + def test_color_mask_RGBa(self, mode: str) -> None: im = getattr(self, "gradient_" + mode).copy() color = "white" @@ -325,7 +332,7 @@ class TestImagingPaste: ], ) - def test_different_sizes(self): + def test_different_sizes(self) -> None: im = Image.new("RGB", (100, 100)) im2 = Image.new("RGB", (50, 50)) diff --git a/Tests/test_image_point.py b/Tests/test_image_point.py index fce45ec4f..05f209351 100644 --- a/Tests/test_image_point.py +++ b/Tests/test_image_point.py @@ -1,10 +1,11 @@ from __future__ import annotations + import pytest from .helper import assert_image_equal, hopper -def test_sanity(): +def test_sanity() -> None: im = hopper() with pytest.raises(ValueError): @@ -38,7 +39,7 @@ def test_sanity(): im.point(lambda x: x // 2) -def test_16bit_lut(): +def test_16bit_lut() -> None: """Tests for 16 bit -> 8 bit lut for converting I->L images see https://github.com/python-pillow/Pillow/issues/440 """ @@ -46,7 +47,7 @@ def test_16bit_lut(): im.point(list(range(256)) * 256, "L") -def test_f_lut(): +def test_f_lut() -> None: """Tests for floating point lut of 8bit gray image""" im = hopper("L") lut = [0.5 * float(x) for x in range(256)] @@ -57,7 +58,7 @@ def test_f_lut(): assert_image_equal(out.convert("L"), im.point(int_lut, "L")) -def test_f_mode(): +def test_f_mode() -> None: im = hopper("F") with pytest.raises(ValueError): im.point(None) diff --git a/Tests/test_image_putalpha.py b/Tests/test_image_putalpha.py index 0ba7e5919..2c92911d1 100644 --- a/Tests/test_image_putalpha.py +++ b/Tests/test_image_putalpha.py @@ -1,8 +1,9 @@ from __future__ import annotations + from PIL import Image -def test_interface(): +def test_interface() -> None: im = Image.new("RGBA", (1, 1), (1, 2, 3, 0)) assert im.getpixel((0, 0)) == (1, 2, 3, 0) @@ -16,7 +17,7 @@ def test_interface(): assert im.getpixel((0, 0)) == (1, 2, 3, 5) -def test_promote(): +def test_promote() -> None: im = Image.new("L", (1, 1), 1) assert im.getpixel((0, 0)) == 1 @@ -39,7 +40,7 @@ def test_promote(): assert im.getpixel((0, 0)) == (1, 2, 3, 4) -def test_readonly(): +def test_readonly() -> None: im = Image.new("RGB", (1, 1), (1, 2, 3)) im.readonly = 1 diff --git a/Tests/test_image_putdata.py b/Tests/test_image_putdata.py index d3cb13e2e..73145faac 100644 --- a/Tests/test_image_putdata.py +++ b/Tests/test_image_putdata.py @@ -1,4 +1,5 @@ from __future__ import annotations + import sys from array import array @@ -9,7 +10,7 @@ from PIL import Image from .helper import assert_image_equal, hopper -def test_sanity(): +def test_sanity() -> None: im1 = hopper() data = list(im1.getdata()) @@ -28,9 +29,9 @@ def test_sanity(): assert_image_equal(im1, im2) -def test_long_integers(): +def test_long_integers() -> None: # see bug-200802-systemerror - def put(value): + def put(value: int) -> tuple[int, int, int, int]: im = Image.new("RGBA", (1, 1)) im.putdata([value]) return im.getpixel((0, 0)) @@ -45,19 +46,19 @@ def test_long_integers(): assert put(sys.maxsize) == (255, 255, 255, 127) -def test_pypy_performance(): +def test_pypy_performance() -> None: im = Image.new("L", (256, 256)) im.putdata(list(range(256)) * 256) -def test_mode_with_L_with_float(): +def test_mode_with_L_with_float() -> None: im = Image.new("L", (1, 1), 0) im.putdata([2.0]) assert im.getpixel((0, 0)) == 2 @pytest.mark.parametrize("mode", ("I", "I;16", "I;16L", "I;16B")) -def test_mode_i(mode): +def test_mode_i(mode: str) -> None: src = hopper("L") data = list(src.getdata()) im = Image.new(mode, src.size, 0) @@ -67,7 +68,7 @@ def test_mode_i(mode): assert list(im.getdata()) == target -def test_mode_F(): +def test_mode_F() -> None: src = hopper("L") data = list(src.getdata()) im = Image.new("F", src.size, 0) @@ -78,7 +79,7 @@ def test_mode_F(): @pytest.mark.parametrize("mode", ("BGR;15", "BGR;16", "BGR;24")) -def test_mode_BGR(mode): +def test_mode_BGR(mode: str) -> None: data = [(16, 32, 49), (32, 32, 98)] im = Image.new(mode, (1, 2)) im.putdata(data) @@ -86,7 +87,7 @@ def test_mode_BGR(mode): assert list(im.getdata()) == data -def test_array_B(): +def test_array_B() -> None: # shouldn't segfault # see https://github.com/python-pillow/Pillow/issues/1008 @@ -97,7 +98,7 @@ def test_array_B(): assert len(im.getdata()) == len(arr) -def test_array_F(): +def test_array_F() -> None: # shouldn't segfault # see https://github.com/python-pillow/Pillow/issues/1008 @@ -108,7 +109,7 @@ def test_array_F(): assert len(im.getdata()) == len(arr) -def test_not_flattened(): +def test_not_flattened() -> None: im = Image.new("L", (1, 1)) with pytest.raises(TypeError): im.putdata([[0]]) diff --git a/Tests/test_image_putpalette.py b/Tests/test_image_putpalette.py index de2d90242..cc7cf58f0 100644 --- a/Tests/test_image_putpalette.py +++ b/Tests/test_image_putpalette.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, ImagePalette @@ -6,8 +7,8 @@ from PIL import Image, ImagePalette from .helper import assert_image_equal, assert_image_equal_tofile, hopper -def test_putpalette(): - def palette(mode): +def test_putpalette() -> None: + def palette(mode: str) -> str | tuple[str, list[int]]: im = hopper(mode).copy() im.putpalette(list(range(256)) * 3) p = im.getpalette() @@ -42,7 +43,7 @@ def test_putpalette(): im.putpalette(list(range(256)) * 3) -def test_imagepalette(): +def test_imagepalette() -> None: im = hopper("P") im.putpalette(ImagePalette.negative()) assert_image_equal_tofile(im.convert("RGB"), "Tests/images/palette_negative.png") @@ -56,7 +57,7 @@ def test_imagepalette(): assert_image_equal_tofile(im.convert("RGB"), "Tests/images/palette_wedge.png") -def test_putpalette_with_alpha_values(): +def test_putpalette_with_alpha_values() -> None: with Image.open("Tests/images/transparent.gif") as im: expected = im.convert("RGBA") @@ -80,19 +81,19 @@ def test_putpalette_with_alpha_values(): ("RGBAX", (1, 2, 3, 4, 0)), ), ) -def test_rgba_palette(mode, palette): +def test_rgba_palette(mode: str, palette: tuple[int, ...]) -> None: im = Image.new("P", (1, 1)) im.putpalette(palette, mode) assert im.getpalette() == [1, 2, 3] assert im.palette.colors == {(1, 2, 3, 4): 0} -def test_empty_palette(): +def test_empty_palette() -> None: im = Image.new("P", (1, 1)) assert im.getpalette() == [] -def test_undefined_palette_index(): +def test_undefined_palette_index() -> None: im = Image.new("P", (1, 1), 3) im.putpalette((1, 2, 3)) assert im.convert("RGB").getpixel((0, 0)) == (0, 0, 0) diff --git a/Tests/test_image_quantize.py b/Tests/test_image_quantize.py index 54c567aae..873a9bb5d 100644 --- a/Tests/test_image_quantize.py +++ b/Tests/test_image_quantize.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from packaging.version import parse as parse_version @@ -7,7 +8,7 @@ from PIL import Image, features from .helper import assert_image_similar, hopper, is_ppc64le, skip_unless_feature -def test_sanity(): +def test_sanity() -> None: image = hopper() converted = image.quantize() assert converted.mode == "P" @@ -20,7 +21,7 @@ def test_sanity(): @skip_unless_feature("libimagequant") -def test_libimagequant_quantize(): +def test_libimagequant_quantize() -> None: image = hopper() if is_ppc64le(): libimagequant = parse_version(features.version_feature("libimagequant")) @@ -32,7 +33,7 @@ def test_libimagequant_quantize(): assert len(converted.getcolors()) == 100 -def test_octree_quantize(): +def test_octree_quantize() -> None: image = hopper() converted = image.quantize(100, Image.Quantize.FASTOCTREE) assert converted.mode == "P" @@ -40,7 +41,7 @@ def test_octree_quantize(): assert len(converted.getcolors()) == 100 -def test_rgba_quantize(): +def test_rgba_quantize() -> None: image = hopper("RGBA") with pytest.raises(ValueError): image.quantize(method=0) @@ -48,7 +49,7 @@ def test_rgba_quantize(): assert image.quantize().convert().mode == "RGBA" -def test_quantize(): +def test_quantize() -> None: with Image.open("Tests/images/caption_6_33_22.png") as image: image = image.convert("RGB") converted = image.quantize() @@ -56,7 +57,7 @@ def test_quantize(): assert_image_similar(converted.convert("RGB"), image, 1) -def test_quantize_no_dither(): +def test_quantize_no_dither() -> None: image = hopper() with Image.open("Tests/images/caption_6_33_22.png") as palette: palette = palette.convert("P") @@ -66,7 +67,7 @@ def test_quantize_no_dither(): assert converted.palette.palette == palette.palette.palette -def test_quantize_no_dither2(): +def test_quantize_no_dither2() -> None: im = Image.new("RGB", (9, 1)) im.putdata([(p,) * 3 for p in range(0, 36, 4)]) @@ -82,7 +83,7 @@ def test_quantize_no_dither2(): assert px[x, 0] == (0 if x < 5 else 1) -def test_quantize_dither_diff(): +def test_quantize_dither_diff() -> None: image = hopper() with Image.open("Tests/images/caption_6_33_22.png") as palette: palette = palette.convert("P") @@ -93,14 +94,14 @@ def test_quantize_dither_diff(): assert dither.tobytes() != nodither.tobytes() -def test_colors(): +def test_colors() -> None: im = hopper() colors = 2 converted = im.quantize(colors) assert len(converted.palette.palette) == colors * len("RGB") -def test_transparent_colors_equal(): +def test_transparent_colors_equal() -> None: im = Image.new("RGBA", (1, 2), (0, 0, 0, 0)) px = im.load() px[0, 1] = (255, 255, 255, 0) @@ -119,7 +120,7 @@ def test_transparent_colors_equal(): (Image.Quantize.FASTOCTREE, (0, 0, 0, 0)), ), ) -def test_palette(method, color): +def test_palette(method: Image.Quantize, color: tuple[int, ...]) -> None: im = Image.new("RGBA" if len(color) == 4 else "RGB", (1, 1), color) converted = im.quantize(method=method) @@ -127,7 +128,7 @@ def test_palette(method, color): assert converted_px[0, 0] == converted.palette.colors[color] -def test_small_palette(): +def test_small_palette() -> None: # Arrange im = hopper() diff --git a/Tests/test_image_reduce.py b/Tests/test_image_reduce.py index a4d0f5107..33b33d6b7 100644 --- a/Tests/test_image_reduce.py +++ b/Tests/test_image_reduce.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, ImageMath, ImageMode @@ -47,7 +48,7 @@ gradients_image.load() ((1, 3), (10, 4)), ), ) -def test_args_factor(size, expected): +def test_args_factor(size: int | tuple[int, int], expected: tuple[int, int]) -> None: im = Image.new("L", (10, 10)) assert expected == im.reduce(size).size @@ -55,7 +56,7 @@ def test_args_factor(size, expected): @pytest.mark.parametrize( "size, expected_error", ((0, ValueError), (2.0, TypeError), ((0, 10), ValueError)) ) -def test_args_factor_error(size, expected_error): +def test_args_factor_error(size: float | tuple[int, int], expected_error) -> None: im = Image.new("L", (10, 10)) with pytest.raises(expected_error): im.reduce(size) @@ -68,7 +69,7 @@ def test_args_factor_error(size, expected_error): ((5, 5, 6, 6), (1, 1)), ), ) -def test_args_box(size, expected): +def test_args_box(size: tuple[int, int, int, int], expected: tuple[int, int]) -> None: im = Image.new("L", (10, 10)) assert expected == im.reduce(2, size).size @@ -85,20 +86,20 @@ def test_args_box(size, expected): ((5, 0, 5, 10), ValueError), ), ) -def test_args_box_error(size, expected_error): +def test_args_box_error(size: str | tuple[int, int, int, int], expected_error) -> None: im = Image.new("L", (10, 10)) with pytest.raises(expected_error): im.reduce(2, size).size @pytest.mark.parametrize("mode", ("P", "1", "I;16")) -def test_unsupported_modes(mode): +def test_unsupported_modes(mode: str) -> None: im = Image.new("P", (10, 10)) with pytest.raises(ValueError): im.reduce(3) -def get_image(mode): +def get_image(mode: str) -> Image.Image: mode_info = ImageMode.getmode(mode) if mode_info.basetype == "L": bands = [gradients_image] @@ -118,14 +119,19 @@ def get_image(mode): return im.crop((0, 0, im.width, im.height - 5)) -def compare_reduce_with_box(im, factor): +def compare_reduce_with_box(im: Image.Image, factor: int | tuple[int, int]) -> None: box = (11, 13, 146, 164) reduced = im.reduce(factor, box=box) reference = im.crop(box).reduce(factor) assert reduced == reference -def compare_reduce_with_reference(im, factor, average_diff=0.4, max_diff=1): +def compare_reduce_with_reference( + im: Image.Image, + factor: int | tuple[int, int], + average_diff: float = 0.4, + max_diff: int = 1, +) -> None: """Image.reduce() should look very similar to Image.resize(BOX). A reference image is compiled from a large source area @@ -170,7 +176,9 @@ def compare_reduce_with_reference(im, factor, average_diff=0.4, max_diff=1): assert_compare_images(reduced, reference, average_diff, max_diff) -def assert_compare_images(a, b, max_average_diff, max_diff=255): +def assert_compare_images( + a: Image.Image, b: Image.Image, max_average_diff: float, max_diff: int = 255 +) -> None: assert a.mode == b.mode, f"got mode {repr(a.mode)}, expected {repr(b.mode)}" assert a.size == b.size, f"got size {repr(a.size)}, expected {repr(b.size)}" @@ -198,20 +206,20 @@ def assert_compare_images(a, b, max_average_diff, max_diff=255): @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_L(factor): +def test_mode_L(factor: int | tuple[int, int]) -> None: im = get_image("L") compare_reduce_with_reference(im, factor) compare_reduce_with_box(im, factor) @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_LA(factor): +def test_mode_LA(factor: int | tuple[int, int]) -> None: im = get_image("LA") compare_reduce_with_reference(im, factor, 0.8, 5) @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_LA_opaque(factor): +def test_mode_LA_opaque(factor: int | tuple[int, int]) -> None: im = get_image("LA") # With opaque alpha, an error should be way smaller. im.putalpha(Image.new("L", im.size, 255)) @@ -220,27 +228,27 @@ def test_mode_LA_opaque(factor): @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_La(factor): +def test_mode_La(factor: int | tuple[int, int]) -> None: im = get_image("La") compare_reduce_with_reference(im, factor) compare_reduce_with_box(im, factor) @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_RGB(factor): +def test_mode_RGB(factor: int | tuple[int, int]) -> None: im = get_image("RGB") compare_reduce_with_reference(im, factor) compare_reduce_with_box(im, factor) @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_RGBA(factor): +def test_mode_RGBA(factor: int | tuple[int, int]) -> None: im = get_image("RGBA") compare_reduce_with_reference(im, factor, 0.8, 5) @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_RGBA_opaque(factor): +def test_mode_RGBA_opaque(factor: int | tuple[int, int]) -> None: im = get_image("RGBA") # With opaque alpha, an error should be way smaller. im.putalpha(Image.new("L", im.size, 255)) @@ -249,27 +257,27 @@ def test_mode_RGBA_opaque(factor): @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_RGBa(factor): +def test_mode_RGBa(factor: int | tuple[int, int]) -> None: im = get_image("RGBa") compare_reduce_with_reference(im, factor) compare_reduce_with_box(im, factor) @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_I(factor): +def test_mode_I(factor: int | tuple[int, int]) -> None: im = get_image("I") compare_reduce_with_reference(im, factor) compare_reduce_with_box(im, factor) @pytest.mark.parametrize("factor", remarkable_factors) -def test_mode_F(factor): +def test_mode_F(factor: int | tuple[int, int]) -> None: im = get_image("F") compare_reduce_with_reference(im, factor, 0, 0) compare_reduce_with_box(im, factor) @skip_unless_feature("jpg_2000") -def test_jpeg2k(): +def test_jpeg2k() -> None: with Image.open("Tests/images/test-card-lossless.jp2") as im: assert im.reduce(2).size == (320, 240) diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index b4bf6c8df..dbe193808 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -1,5 +1,7 @@ from __future__ import annotations + from contextlib import contextmanager +from typing import Generator import pytest @@ -15,7 +17,7 @@ from .helper import ( class TestImagingResampleVulnerability: # see https://github.com/python-pillow/Pillow/issues/1710 - def test_overflow(self): + def test_overflow(self) -> None: im = hopper("L") size_too_large = 0x100000008 // 4 size_normal = 1000 # unimportant @@ -27,7 +29,7 @@ class TestImagingResampleVulnerability: # any resampling filter will do here im.im.resize((xsize, ysize), Image.Resampling.BILINEAR) - def test_invalid_size(self): + def test_invalid_size(self) -> None: im = hopper() # Should not crash @@ -39,7 +41,7 @@ class TestImagingResampleVulnerability: with pytest.raises(ValueError): im.resize((100, -100)) - def test_modify_after_resizing(self): + def test_modify_after_resizing(self) -> None: im = hopper("RGB") # get copy with same size copy = im.resize(im.size) @@ -50,7 +52,7 @@ class TestImagingResampleVulnerability: class TestImagingCoreResampleAccuracy: - def make_case(self, mode, size, color): + def make_case(self, mode: str, size: tuple[int, int], color: int) -> Image.Image: """Makes a sample image with two dark and two bright squares. For example: e0 e0 1f 1f @@ -65,7 +67,7 @@ class TestImagingCoreResampleAccuracy: return Image.merge(mode, [case] * len(mode)) - def make_sample(self, data, size): + def make_sample(self, data: str, size: tuple[int, int]) -> Image.Image: """Restores a sample image from given data string which contains hex-encoded pixels from the top left fourth of a sample. """ @@ -82,7 +84,7 @@ class TestImagingCoreResampleAccuracy: s_px[size[0] - x - 1, y] = 255 - val return sample - def check_case(self, case, sample): + def check_case(self, case: Image.Image, sample: Image.Image) -> None: s_px = sample.load() c_px = case.load() for y in range(case.size[1]): @@ -94,7 +96,7 @@ class TestImagingCoreResampleAccuracy: ) assert s_px[x, y] == c_px[x, y], message - def serialize_image(self, image): + def serialize_image(self, image: Image.Image) -> str: s_px = image.load() return "\n".join( " ".join(f"{s_px[x, y]:02x}" for x in range(image.size[0])) @@ -102,7 +104,7 @@ class TestImagingCoreResampleAccuracy: ) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_reduce_box(self, mode): + def test_reduce_box(self, mode: str) -> None: case = self.make_case(mode, (8, 8), 0xE1) case = case.resize((4, 4), Image.Resampling.BOX) # fmt: off @@ -113,7 +115,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (4, 4))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_reduce_bilinear(self, mode): + def test_reduce_bilinear(self, mode: str) -> None: case = self.make_case(mode, (8, 8), 0xE1) case = case.resize((4, 4), Image.Resampling.BILINEAR) # fmt: off @@ -124,7 +126,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (4, 4))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_reduce_hamming(self, mode): + def test_reduce_hamming(self, mode: str) -> None: case = self.make_case(mode, (8, 8), 0xE1) case = case.resize((4, 4), Image.Resampling.HAMMING) # fmt: off @@ -135,7 +137,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (4, 4))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_reduce_bicubic(self, mode): + def test_reduce_bicubic(self, mode: str) -> None: case = self.make_case(mode, (12, 12), 0xE1) case = case.resize((6, 6), Image.Resampling.BICUBIC) # fmt: off @@ -147,7 +149,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (6, 6))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_reduce_lanczos(self, mode): + def test_reduce_lanczos(self, mode: str) -> None: case = self.make_case(mode, (16, 16), 0xE1) case = case.resize((8, 8), Image.Resampling.LANCZOS) # fmt: off @@ -160,7 +162,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (8, 8))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_enlarge_box(self, mode): + def test_enlarge_box(self, mode: str) -> None: case = self.make_case(mode, (2, 2), 0xE1) case = case.resize((4, 4), Image.Resampling.BOX) # fmt: off @@ -171,7 +173,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (4, 4))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_enlarge_bilinear(self, mode): + def test_enlarge_bilinear(self, mode: str) -> None: case = self.make_case(mode, (2, 2), 0xE1) case = case.resize((4, 4), Image.Resampling.BILINEAR) # fmt: off @@ -182,7 +184,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (4, 4))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_enlarge_hamming(self, mode): + def test_enlarge_hamming(self, mode: str) -> None: case = self.make_case(mode, (2, 2), 0xE1) case = case.resize((4, 4), Image.Resampling.HAMMING) # fmt: off @@ -193,7 +195,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (4, 4))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_enlarge_bicubic(self, mode): + def test_enlarge_bicubic(self, mode: str) -> None: case = self.make_case(mode, (4, 4), 0xE1) case = case.resize((8, 8), Image.Resampling.BICUBIC) # fmt: off @@ -206,7 +208,7 @@ class TestImagingCoreResampleAccuracy: self.check_case(channel, self.make_sample(data, (8, 8))) @pytest.mark.parametrize("mode", ("RGBX", "RGB", "La", "L")) - def test_enlarge_lanczos(self, mode): + def test_enlarge_lanczos(self, mode: str) -> None: case = self.make_case(mode, (6, 6), 0xE1) case = case.resize((12, 12), Image.Resampling.LANCZOS) data = ( @@ -220,7 +222,7 @@ class TestImagingCoreResampleAccuracy: for channel in case.split(): self.check_case(channel, self.make_sample(data, (12, 12))) - def test_box_filter_correct_range(self): + def test_box_filter_correct_range(self) -> None: im = Image.new("RGB", (8, 8), "#1688ff").resize( (100, 100), Image.Resampling.BOX ) @@ -229,11 +231,13 @@ class TestImagingCoreResampleAccuracy: class TestCoreResampleConsistency: - def make_case(self, mode, fill): + def make_case( + self, mode: str, fill: tuple[int, int, int] | float + ) -> tuple[Image.Image, tuple[int, ...]]: im = Image.new(mode, (512, 9), fill) return im.resize((9, 512), Image.Resampling.LANCZOS), im.load()[0, 0] - def run_case(self, case): + def run_case(self, case: tuple[Image.Image, int | tuple[int, ...]]) -> None: channel, color = case px = channel.load() for x in range(channel.size[0]): @@ -242,7 +246,7 @@ class TestCoreResampleConsistency: message = f"{px[x, y]} != {color} for pixel {(x, y)}" assert px[x, y] == color, message - def test_8u(self): + def test_8u(self) -> None: im, color = self.make_case("RGB", (0, 64, 255)) r, g, b = im.split() self.run_case((r, color[0])) @@ -250,13 +254,13 @@ class TestCoreResampleConsistency: self.run_case((b, color[2])) self.run_case(self.make_case("L", 12)) - def test_32i(self): + def test_32i(self) -> None: self.run_case(self.make_case("I", 12)) self.run_case(self.make_case("I", 0x7FFFFFFF)) self.run_case(self.make_case("I", -12)) self.run_case(self.make_case("I", -1 << 31)) - def test_32f(self): + def test_32f(self) -> None: self.run_case(self.make_case("F", 1)) self.run_case(self.make_case("F", 3.40282306074e38)) self.run_case(self.make_case("F", 1.175494e-38)) @@ -264,7 +268,7 @@ class TestCoreResampleConsistency: class TestCoreResampleAlphaCorrect: - def make_levels_case(self, mode): + def make_levels_case(self, mode: str) -> Image.Image: i = Image.new(mode, (256, 16)) px = i.load() for y in range(i.size[1]): @@ -274,7 +278,7 @@ class TestCoreResampleAlphaCorrect: px[x, y] = tuple(pix) return i - def run_levels_case(self, i): + def run_levels_case(self, i: Image.Image) -> None: px = i.load() for y in range(i.size[1]): used_colors = {px[x, y][0] for x in range(i.size[0])} @@ -284,7 +288,7 @@ class TestCoreResampleAlphaCorrect: ) @pytest.mark.xfail(reason="Current implementation isn't precise enough") - def test_levels_rgba(self): + def test_levels_rgba(self) -> None: case = self.make_levels_case("RGBA") self.run_levels_case(case.resize((512, 32), Image.Resampling.BOX)) self.run_levels_case(case.resize((512, 32), Image.Resampling.BILINEAR)) @@ -293,7 +297,7 @@ class TestCoreResampleAlphaCorrect: self.run_levels_case(case.resize((512, 32), Image.Resampling.LANCZOS)) @pytest.mark.xfail(reason="Current implementation isn't precise enough") - def test_levels_la(self): + def test_levels_la(self) -> None: case = self.make_levels_case("LA") self.run_levels_case(case.resize((512, 32), Image.Resampling.BOX)) self.run_levels_case(case.resize((512, 32), Image.Resampling.BILINEAR)) @@ -301,7 +305,9 @@ class TestCoreResampleAlphaCorrect: self.run_levels_case(case.resize((512, 32), Image.Resampling.BICUBIC)) self.run_levels_case(case.resize((512, 32), Image.Resampling.LANCZOS)) - def make_dirty_case(self, mode, clean_pixel, dirty_pixel): + def make_dirty_case( + self, mode: str, clean_pixel: tuple[int, ...], dirty_pixel: tuple[int, ...] + ) -> Image.Image: i = Image.new(mode, (64, 64), dirty_pixel) px = i.load() xdiv4 = i.size[0] // 4 @@ -311,7 +317,7 @@ class TestCoreResampleAlphaCorrect: px[x + xdiv4, y + ydiv4] = clean_pixel return i - def run_dirty_case(self, i, clean_pixel): + def run_dirty_case(self, i: Image.Image, clean_pixel: tuple[int, ...]) -> None: px = i.load() for y in range(i.size[1]): for x in range(i.size[0]): @@ -322,7 +328,7 @@ class TestCoreResampleAlphaCorrect: ) assert px[x, y][:3] == clean_pixel, message - def test_dirty_pixels_rgba(self): + def test_dirty_pixels_rgba(self) -> None: case = self.make_dirty_case("RGBA", (255, 255, 0, 128), (0, 0, 255, 0)) self.run_dirty_case(case.resize((20, 20), Image.Resampling.BOX), (255, 255, 0)) self.run_dirty_case( @@ -338,7 +344,7 @@ class TestCoreResampleAlphaCorrect: case.resize((20, 20), Image.Resampling.LANCZOS), (255, 255, 0) ) - def test_dirty_pixels_la(self): + def test_dirty_pixels_la(self) -> None: case = self.make_dirty_case("LA", (255, 128), (0, 0)) self.run_dirty_case(case.resize((20, 20), Image.Resampling.BOX), (255,)) self.run_dirty_case(case.resize((20, 20), Image.Resampling.BILINEAR), (255,)) @@ -349,27 +355,27 @@ class TestCoreResampleAlphaCorrect: class TestCoreResamplePasses: @contextmanager - def count(self, diff): + def count(self, diff: int) -> Generator[None, None, None]: count = Image.core.get_stats()["new_count"] yield assert Image.core.get_stats()["new_count"] - count == diff - def test_horizontal(self): + def test_horizontal(self) -> None: im = hopper("L") with self.count(1): im.resize((im.size[0] - 10, im.size[1]), Image.Resampling.BILINEAR) - def test_vertical(self): + def test_vertical(self) -> None: im = hopper("L") with self.count(1): im.resize((im.size[0], im.size[1] - 10), Image.Resampling.BILINEAR) - def test_both(self): + def test_both(self) -> None: im = hopper("L") with self.count(2): im.resize((im.size[0] - 10, im.size[1] - 10), Image.Resampling.BILINEAR) - def test_box_horizontal(self): + def test_box_horizontal(self) -> None: im = hopper("L") box = (20, 0, im.size[0] - 20, im.size[1]) with self.count(1): @@ -379,7 +385,7 @@ class TestCoreResamplePasses: cropped = im.crop(box).resize(im.size, Image.Resampling.BILINEAR) assert_image_similar(with_box, cropped, 0.1) - def test_box_vertical(self): + def test_box_vertical(self) -> None: im = hopper("L") box = (0, 20, im.size[0], im.size[1] - 20) with self.count(1): @@ -391,7 +397,7 @@ class TestCoreResamplePasses: class TestCoreResampleCoefficients: - def test_reduce(self): + def test_reduce(self) -> None: test_color = 254 for size in range(400000, 400010, 2): @@ -403,7 +409,7 @@ class TestCoreResampleCoefficients: if px[2, 0] != test_color // 2: assert test_color // 2 == px[2, 0] - def test_nonzero_coefficients(self): + def test_non_zero_coefficients(self) -> None: # regression test for the wrong coefficients calculation # due to bug https://github.com/python-pillow/Pillow/issues/2161 im = Image.new("RGBA", (1280, 1280), (0x20, 0x40, 0x60, 0xFF)) @@ -431,7 +437,7 @@ class TestCoreResampleBox: Image.Resampling.LANCZOS, ), ) - def test_wrong_arguments(self, resample): + def test_wrong_arguments(self, resample: Image.Resampling) -> None: im = hopper() im.resize((32, 32), resample, (0, 0, im.width, im.height)) im.resize((32, 32), resample, (20, 20, im.width, im.height)) @@ -458,8 +464,12 @@ class TestCoreResampleBox: with pytest.raises(ValueError, match="can't exceed"): im.resize((32, 32), resample, (0, 0, im.width, im.height + 1)) - def resize_tiled(self, im, dst_size, xtiles, ytiles): - def split_range(size, tiles): + def resize_tiled( + self, im: Image.Image, dst_size: tuple[int, int], xtiles: int, ytiles: int + ) -> Image.Image: + def split_range( + size: int, tiles: int + ) -> Generator[tuple[int, int], None, None]: scale = size / tiles for i in range(tiles): yield int(round(scale * i)), int(round(scale * (i + 1))) @@ -477,7 +487,7 @@ class TestCoreResampleBox: @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_tiles(self): + def test_tiles(self) -> None: with Image.open("Tests/images/flower.jpg") as im: assert im.size == (480, 360) dst_size = (251, 188) @@ -490,7 +500,7 @@ class TestCoreResampleBox: @mark_if_feature_version( pytest.mark.valgrind_known_error, "libjpeg_turbo", "2.0", reason="Known Failing" ) - def test_subsample(self): + def test_subsample(self) -> None: # This test shows advantages of the subpixel resizing # after supersampling (e.g. during JPEG decoding). with Image.open("Tests/images/flower.jpg") as im: @@ -517,14 +527,14 @@ class TestCoreResampleBox: @pytest.mark.parametrize( "resample", (Image.Resampling.NEAREST, Image.Resampling.BILINEAR) ) - def test_formats(self, mode, resample): + def test_formats(self, mode: str, resample: Image.Resampling) -> None: im = hopper(mode) box = (20, 20, im.size[0] - 20, im.size[1] - 20) with_box = im.resize((32, 32), resample, box) cropped = im.crop(box).resize((32, 32), resample) assert_image_similar(cropped, with_box, 0.4) - def test_passthrough(self): + def test_passthrough(self) -> None: # When no resize is required im = hopper() @@ -538,7 +548,7 @@ class TestCoreResampleBox: assert res.size == size assert_image_equal(res, im.crop(box), f">>> {size} {box}") - def test_no_passthrough(self): + def test_no_passthrough(self) -> None: # When resize is required im = hopper() @@ -557,7 +567,7 @@ class TestCoreResampleBox: @pytest.mark.parametrize( "flt", (Image.Resampling.NEAREST, Image.Resampling.BICUBIC) ) - def test_skip_horizontal(self, flt): + def test_skip_horizontal(self, flt: Image.Resampling) -> None: # Can skip resize for one dimension im = hopper() @@ -580,7 +590,7 @@ class TestCoreResampleBox: @pytest.mark.parametrize( "flt", (Image.Resampling.NEAREST, Image.Resampling.BICUBIC) ) - def test_skip_vertical(self, flt): + def test_skip_vertical(self, flt: Image.Resampling) -> None: # Can skip resize for one dimension im = hopper() diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py index 0d3b43ee2..64098f80f 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -1,8 +1,12 @@ """ Tests for resize functionality. """ + from __future__ import annotations + from itertools import permutations +from pathlib import Path +from typing import Generator import pytest @@ -18,7 +22,9 @@ from .helper import ( class TestImagingCoreResize: - def resize(self, im, size, f): + def resize( + self, im: Image.Image, size: tuple[int, int], f: Image.Resampling + ) -> Image.Image: # Image class independent version of resize. im.load() return im._new(im.im.resize(size, f)) @@ -26,14 +32,14 @@ class TestImagingCoreResize: @pytest.mark.parametrize( "mode", ("1", "P", "L", "I", "F", "RGB", "RGBA", "CMYK", "YCbCr", "I;16") ) - def test_nearest_mode(self, mode): + def test_nearest_mode(self, mode: str) -> None: im = hopper(mode) r = self.resize(im, (15, 12), Image.Resampling.NEAREST) assert r.mode == mode assert r.size == (15, 12) assert r.im.bands == im.im.bands - def test_convolution_modes(self): + def test_convolution_modes(self) -> None: with pytest.raises(ValueError): self.resize(hopper("1"), (15, 12), Image.Resampling.BILINEAR) with pytest.raises(ValueError): @@ -58,7 +64,7 @@ class TestImagingCoreResize: Image.Resampling.LANCZOS, ), ) - def test_reduce_filters(self, resample): + def test_reduce_filters(self, resample: Image.Resampling) -> None: r = self.resize(hopper("RGB"), (15, 12), resample) assert r.mode == "RGB" assert r.size == (15, 12) @@ -74,7 +80,7 @@ class TestImagingCoreResize: Image.Resampling.LANCZOS, ), ) - def test_enlarge_filters(self, resample): + def test_enlarge_filters(self, resample: Image.Resampling) -> None: r = self.resize(hopper("RGB"), (212, 195), resample) assert r.mode == "RGB" assert r.size == (212, 195) @@ -98,7 +104,9 @@ class TestImagingCoreResize: ("LA", ("filled", "dirty")), ), ) - def test_endianness(self, resample, mode, channels_set): + def test_endianness( + self, resample: Image.Resampling, mode: str, channels_set: tuple[str, ...] + ) -> None: # Make an image with one colored pixel, in one channel. # When resized, that channel should be the same as a GS image. # Other channels should be unaffected. @@ -138,17 +146,17 @@ class TestImagingCoreResize: Image.Resampling.LANCZOS, ), ) - def test_enlarge_zero(self, resample): + def test_enlarge_zero(self, resample: Image.Resampling) -> None: r = self.resize(Image.new("RGB", (0, 0), "white"), (212, 195), resample) assert r.mode == "RGB" assert r.size == (212, 195) assert r.getdata()[0] == (0, 0, 0) - def test_unknown_filter(self): + def test_unknown_filter(self) -> None: with pytest.raises(ValueError): - self.resize(hopper(), (10, 10), 9) + self.resize(hopper(), (10, 10), 9) # type: ignore[arg-type] - def test_cross_platform(self, tmp_path): + def test_cross_platform(self, tmp_path: Path) -> None: # This test is intended for only check for consistent behaviour across # platforms. So if a future Pillow change requires that the test file # be updated, that is okay. @@ -161,7 +169,7 @@ class TestImagingCoreResize: @pytest.fixture -def gradients_image(): +def gradients_image() -> Generator[Image.Image, None, None]: with Image.open("Tests/images/radial_gradients.png") as im: im.load() try: @@ -171,7 +179,7 @@ def gradients_image(): class TestReducingGapResize: - def test_reducing_gap_values(self, gradients_image): + def test_reducing_gap_values(self, gradients_image: Image.Image) -> None: ref = gradients_image.resize( (52, 34), Image.Resampling.BICUBIC, reducing_gap=None ) @@ -190,7 +198,12 @@ class TestReducingGapResize: "box, epsilon", ((None, 4), ((1.1, 2.2, 510.8, 510.9), 4), ((3, 10, 410, 256), 10)), ) - def test_reducing_gap_1(self, gradients_image, box, epsilon): + def test_reducing_gap_1( + self, + gradients_image: Image.Image, + box: tuple[float, float, float, float], + epsilon: float, + ) -> None: ref = gradients_image.resize((52, 34), Image.Resampling.BICUBIC, box=box) im = gradients_image.resize( (52, 34), Image.Resampling.BICUBIC, box=box, reducing_gap=1.0 @@ -205,7 +218,12 @@ class TestReducingGapResize: "box, epsilon", ((None, 1.5), ((1.1, 2.2, 510.8, 510.9), 1.5), ((3, 10, 410, 256), 1)), ) - def test_reducing_gap_2(self, gradients_image, box, epsilon): + def test_reducing_gap_2( + self, + gradients_image: Image.Image, + box: tuple[float, float, float, float], + epsilon: float, + ) -> None: ref = gradients_image.resize((52, 34), Image.Resampling.BICUBIC, box=box) im = gradients_image.resize( (52, 34), Image.Resampling.BICUBIC, box=box, reducing_gap=2.0 @@ -220,7 +238,12 @@ class TestReducingGapResize: "box, epsilon", ((None, 1), ((1.1, 2.2, 510.8, 510.9), 1), ((3, 10, 410, 256), 0.5)), ) - def test_reducing_gap_3(self, gradients_image, box, epsilon): + def test_reducing_gap_3( + self, + gradients_image: Image.Image, + box: tuple[float, float, float, float], + epsilon: float, + ) -> None: ref = gradients_image.resize((52, 34), Image.Resampling.BICUBIC, box=box) im = gradients_image.resize( (52, 34), Image.Resampling.BICUBIC, box=box, reducing_gap=3.0 @@ -232,7 +255,9 @@ class TestReducingGapResize: assert_image_similar(ref, im, epsilon) @pytest.mark.parametrize("box", (None, (1.1, 2.2, 510.8, 510.9), (3, 10, 410, 256))) - def test_reducing_gap_8(self, gradients_image, box): + def test_reducing_gap_8( + self, gradients_image: Image.Image, box: tuple[float, float, float, float] + ) -> None: ref = gradients_image.resize((52, 34), Image.Resampling.BICUBIC, box=box) im = gradients_image.resize( (52, 34), Image.Resampling.BICUBIC, box=box, reducing_gap=8.0 @@ -244,7 +269,12 @@ class TestReducingGapResize: "box, epsilon", (((0, 0, 512, 512), 5.5), ((0.9, 1.7, 128, 128), 9.5)), ) - def test_box_filter(self, gradients_image, box, epsilon): + def test_box_filter( + self, + gradients_image: Image.Image, + box: tuple[float, float, float, float], + epsilon: float, + ) -> None: ref = gradients_image.resize((52, 34), Image.Resampling.BOX, box=box) im = gradients_image.resize( (52, 34), Image.Resampling.BOX, box=box, reducing_gap=1.0 @@ -254,8 +284,8 @@ class TestReducingGapResize: class TestImageResize: - def test_resize(self): - def resize(mode, size): + def test_resize(self) -> None: + def resize(mode: str, size: tuple[int, int]) -> None: out = hopper(mode).resize(size) assert out.mode == mode assert out.size == size @@ -270,7 +300,7 @@ class TestImageResize: im.resize((10, 10), "unknown") @skip_unless_feature("libtiff") - def test_load_first(self): + def test_load_first(self) -> None: # load() may change the size of the image # Test that resize() is calling it before getting the size with Image.open("Tests/images/g4_orientation_5.tif") as im: @@ -278,13 +308,13 @@ class TestImageResize: assert im.size == (64, 64) @pytest.mark.parametrize("mode", ("L", "RGB", "I", "F")) - def test_default_filter_bicubic(self, mode): + def test_default_filter_bicubic(self, mode: str) -> None: im = hopper(mode) assert im.resize((20, 20), Image.Resampling.BICUBIC) == im.resize((20, 20)) @pytest.mark.parametrize( "mode", ("1", "P", "I;16", "I;16L", "I;16B", "BGR;15", "BGR;16") ) - def test_default_filter_nearest(self, mode): + def test_default_filter_nearest(self, mode: str) -> None: im = hopper(mode) assert im.resize((20, 20), Image.Resampling.NEAREST) == im.resize((20, 20)) diff --git a/Tests/test_image_rotate.py b/Tests/test_image_rotate.py index 0931aa32d..c10c96da6 100644 --- a/Tests/test_image_rotate.py +++ b/Tests/test_image_rotate.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image @@ -11,7 +12,13 @@ from .helper import ( ) -def rotate(im, mode, angle, center=None, translate=None): +def rotate( + im: Image.Image, + mode: str, + angle: int, + center: tuple[int, int] | None = None, + translate: tuple[int, int] | None = None, +) -> None: out = im.rotate(angle, center=center, translate=translate) assert out.mode == mode assert out.size == im.size # default rotate clips output @@ -26,13 +33,13 @@ def rotate(im, mode, angle, center=None, translate=None): @pytest.mark.parametrize("mode", ("1", "P", "L", "RGB", "I", "F")) -def test_mode(mode): +def test_mode(mode: str) -> None: im = hopper(mode) rotate(im, mode, 45) @pytest.mark.parametrize("angle", (0, 90, 180, 270)) -def test_angle(angle): +def test_angle(angle: int) -> None: with Image.open("Tests/images/test-card.png") as im: rotate(im, im.mode, angle) @@ -41,12 +48,12 @@ def test_angle(angle): @pytest.mark.parametrize("angle", (0, 45, 90, 180, 270)) -def test_zero(angle): +def test_zero(angle: int) -> None: im = Image.new("RGB", (0, 0)) rotate(im, im.mode, angle) -def test_resample(): +def test_resample() -> None: # Target image creation, inspected by eye. # >>> im = Image.open('Tests/images/hopper.ppm') # >>> im = im.rotate(45, resample=Image.Resampling.BICUBIC, expand=True) @@ -63,7 +70,7 @@ def test_resample(): assert_image_similar(im, target, epsilon) -def test_center_0(): +def test_center_0() -> None: im = hopper() im = im.rotate(45, center=(0, 0), resample=Image.Resampling.BICUBIC) @@ -74,7 +81,7 @@ def test_center_0(): assert_image_similar(im, target, 15) -def test_center_14(): +def test_center_14() -> None: im = hopper() im = im.rotate(45, center=(14, 14), resample=Image.Resampling.BICUBIC) @@ -85,7 +92,7 @@ def test_center_14(): assert_image_similar(im, target, 10) -def test_translate(): +def test_translate() -> None: im = hopper() with Image.open("Tests/images/hopper_45.png") as target: target_origin = (target.size[1] / 2 - 64) - 5 @@ -98,7 +105,7 @@ def test_translate(): assert_image_similar(im, target, 1) -def test_fastpath_center(): +def test_fastpath_center() -> None: # if the center is -1,-1 and we rotate by 90<=x<=270 the # resulting image should be black for angle in (90, 180, 270): @@ -106,7 +113,7 @@ def test_fastpath_center(): assert_image_equal(im, Image.new("RGB", im.size, "black")) -def test_fastpath_translate(): +def test_fastpath_translate() -> None: # if we post-translate by -128 # resulting image should be black for angle in (0, 90, 180, 270): @@ -114,26 +121,26 @@ def test_fastpath_translate(): assert_image_equal(im, Image.new("RGB", im.size, "black")) -def test_center(): +def test_center() -> None: im = hopper() rotate(im, im.mode, 45, center=(0, 0)) rotate(im, im.mode, 45, translate=(im.size[0] / 2, 0)) rotate(im, im.mode, 45, center=(0, 0), translate=(im.size[0] / 2, 0)) -def test_rotate_no_fill(): +def test_rotate_no_fill() -> None: im = Image.new("RGB", (100, 100), "green") im = im.rotate(45) assert_image_equal_tofile(im, "Tests/images/rotate_45_no_fill.png") -def test_rotate_with_fill(): +def test_rotate_with_fill() -> None: im = Image.new("RGB", (100, 100), "green") im = im.rotate(45, fillcolor="white") assert_image_equal_tofile(im, "Tests/images/rotate_45_with_fill.png") -def test_alpha_rotate_no_fill(): +def test_alpha_rotate_no_fill() -> None: # Alpha images are handled differently internally im = Image.new("RGBA", (10, 10), "green") im = im.rotate(45, expand=1) @@ -141,7 +148,7 @@ def test_alpha_rotate_no_fill(): assert corner == (0, 0, 0, 0) -def test_alpha_rotate_with_fill(): +def test_alpha_rotate_with_fill() -> None: # Alpha images are handled differently internally im = Image.new("RGBA", (10, 10), "green") im = im.rotate(45, expand=1, fillcolor=(255, 0, 0, 255)) diff --git a/Tests/test_image_split.py b/Tests/test_image_split.py index 707508250..3385f81f5 100644 --- a/Tests/test_image_split.py +++ b/Tests/test_image_split.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import Image, features @@ -6,8 +9,8 @@ from PIL import Image, features from .helper import assert_image_equal, hopper -def test_split(): - def split(mode): +def test_split() -> None: + def split(mode: str) -> list[tuple[str, int, int]]: layers = hopper(mode).split() return [(i.mode, i.size[0], i.size[1]) for i in layers] @@ -35,18 +38,18 @@ def test_split(): @pytest.mark.parametrize( "mode", ("1", "L", "I", "F", "P", "RGB", "RGBA", "CMYK", "YCbCr") ) -def test_split_merge(mode): +def test_split_merge(mode: str) -> None: expected = Image.merge(mode, hopper(mode).split()) assert_image_equal(hopper(mode), expected) -def test_split_open(tmp_path): +def test_split_open(tmp_path: Path) -> None: if features.check("zlib"): test_file = str(tmp_path / "temp.png") else: test_file = str(tmp_path / "temp.pcx") - def split_open(mode): + def split_open(mode: str) -> int: hopper(mode).save(test_file) with Image.open(test_file) as im: return len(im.split()) diff --git a/Tests/test_image_thumbnail.py b/Tests/test_image_thumbnail.py index 9e6796ca2..2ca1d2cfc 100644 --- a/Tests/test_image_thumbnail.py +++ b/Tests/test_image_thumbnail.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image @@ -13,14 +14,14 @@ from .helper import ( ) -def test_sanity(): +def test_sanity() -> None: im = hopper() assert im.thumbnail((100, 100)) is None assert im.size == (100, 100) -def test_aspect(): +def test_aspect() -> None: im = Image.new("L", (128, 128)) im.thumbnail((100, 100)) assert im.size == (100, 100) @@ -66,19 +67,19 @@ def test_aspect(): assert im.size == (75, 23) # ratio is 3.260869565217 -def test_division_by_zero(): +def test_division_by_zero() -> None: im = Image.new("L", (200, 2)) im.thumbnail((75, 75)) assert im.size == (75, 1) -def test_float(): +def test_float() -> None: im = Image.new("L", (128, 128)) im.thumbnail((99.9, 99.9)) assert im.size == (99, 99) -def test_no_resize(): +def test_no_resize() -> None: # Check that draft() can resize the image to the destination size with Image.open("Tests/images/hopper.jpg") as im: im.draft(None, (64, 64)) @@ -91,7 +92,7 @@ def test_no_resize(): @skip_unless_feature("libtiff") -def test_load_first(): +def test_load_first() -> None: # load() may change the size of the image # Test that thumbnail() is calling it before performing size calculations with Image.open("Tests/images/g4_orientation_5.tif") as im: @@ -105,12 +106,12 @@ def test_load_first(): assert im.size == (590, 88) -def test_load_first_unless_jpeg(): +def test_load_first_unless_jpeg() -> None: # Test that thumbnail() still uses draft() for JPEG with Image.open("Tests/images/hopper.jpg") as im: draft = im.draft - def im_draft(mode, size): + def im_draft(mode: str, size: tuple[int, int]): result = draft(mode, size) assert result is not None @@ -123,7 +124,7 @@ def test_load_first_unless_jpeg(): # valgrind test is failing with memory allocated in libjpeg @pytest.mark.valgrind_known_error(reason="Known Failing") -def test_DCT_scaling_edges(): +def test_DCT_scaling_edges() -> None: # Make an image with red borders and size (N * 8) + 1 to cross DCT grid im = Image.new("RGB", (257, 257), "red") im.paste(Image.new("RGB", (235, 235)), (11, 11)) @@ -137,7 +138,7 @@ def test_DCT_scaling_edges(): assert_image_similar(thumb, ref, 1.5) -def test_reducing_gap_values(): +def test_reducing_gap_values() -> None: im = hopper() im.thumbnail((18, 18), Image.Resampling.BICUBIC) @@ -154,7 +155,7 @@ def test_reducing_gap_values(): assert_image_similar(ref, im, 3.5) -def test_reducing_gap_for_DCT_scaling(): +def test_reducing_gap_for_DCT_scaling() -> None: with Image.open("Tests/images/hopper.jpg") as ref: # thumbnail should call draft with reducing_gap scale ref.draft(None, (18 * 3, 18 * 3)) diff --git a/Tests/test_image_tobitmap.py b/Tests/test_image_tobitmap.py index 156b9919d..f7a3cc41d 100644 --- a/Tests/test_image_tobitmap.py +++ b/Tests/test_image_tobitmap.py @@ -1,10 +1,11 @@ from __future__ import annotations + import pytest from .helper import assert_image_equal, fromstring, hopper -def test_sanity(): +def test_sanity() -> None: with pytest.raises(ValueError): hopper().tobitmap() diff --git a/Tests/test_image_tobytes.py b/Tests/test_image_tobytes.py index f6042bca5..d32b6c09b 100644 --- a/Tests/test_image_tobytes.py +++ b/Tests/test_image_tobytes.py @@ -1,7 +1,8 @@ from __future__ import annotations + from .helper import hopper -def test_sanity(): +def test_sanity() -> None: data = hopper().tobytes() assert isinstance(data, bytes) diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index 15939ef64..638d12247 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -1,5 +1,7 @@ from __future__ import annotations + import math +from typing import Callable import pytest @@ -9,21 +11,28 @@ from .helper import assert_image_equal, assert_image_similar, hopper class TestImageTransform: - def test_sanity(self): - im = Image.new("L", (100, 100)) + def test_sanity(self) -> None: + im = hopper() - seq = tuple(range(10)) + for transform in ( + ImageTransform.AffineTransform((1, 0, 0, 0, 1, 0)), + ImageTransform.PerspectiveTransform((1, 0, 0, 0, 1, 0, 0, 0)), + ImageTransform.ExtentTransform((0, 0) + im.size), + ImageTransform.QuadTransform( + (0, 0, 0, im.height, im.width, im.height, im.width, 0) + ), + ImageTransform.MeshTransform( + [ + ( + (0, 0) + im.size, + (0, 0, 0, im.height, im.width, im.height, im.width, 0), + ) + ] + ), + ): + assert_image_equal(im, im.transform(im.size, transform)) - transform = ImageTransform.AffineTransform(seq[:6]) - im.transform((100, 100), transform) - transform = ImageTransform.ExtentTransform(seq[:4]) - im.transform((100, 100), transform) - transform = ImageTransform.QuadTransform(seq[:8]) - im.transform((100, 100), transform) - transform = ImageTransform.MeshTransform([(seq[:4], seq[:8])]) - im.transform((100, 100), transform) - - def test_info(self): + def test_info(self) -> None: comment = b"File written by Adobe Photoshop\xa8 4.0" with Image.open("Tests/images/hopper.gif") as im: @@ -33,14 +42,14 @@ class TestImageTransform: new_im = im.transform((100, 100), transform) assert new_im.info["comment"] == comment - def test_palette(self): + def test_palette(self) -> None: with Image.open("Tests/images/hopper.gif") as im: transformed = im.transform( im.size, Image.Transform.AFFINE, [1, 0, 0, 0, 1, 0] ) assert im.palette.palette == transformed.palette.palette - def test_extent(self): + def test_extent(self) -> None: im = hopper("RGB") (w, h) = im.size transformed = im.transform( @@ -55,7 +64,7 @@ class TestImageTransform: # undone -- precision? assert_image_similar(transformed, scaled, 23) - def test_quad(self): + def test_quad(self) -> None: # one simple quad transform, equivalent to scale & crop upper left quad im = hopper("RGB") (w, h) = im.size @@ -83,7 +92,7 @@ class TestImageTransform: ("LA", (76, 0)), ), ) - def test_fill(self, mode, expected_pixel): + def test_fill(self, mode: str, expected_pixel: tuple[int, ...]) -> None: im = hopper(mode) (w, h) = im.size transformed = im.transform( @@ -95,7 +104,7 @@ class TestImageTransform: ) assert transformed.getpixel((w - 1, h - 1)) == expected_pixel - def test_mesh(self): + def test_mesh(self) -> None: # this should be a checkerboard of halfsized hoppers in ul, lr im = hopper("RGBA") (w, h) = im.size @@ -134,7 +143,9 @@ class TestImageTransform: assert_image_equal(blank, transformed.crop((w // 2, 0, w, h // 2))) assert_image_equal(blank, transformed.crop((0, h // 2, w // 2, h))) - def _test_alpha_premult(self, op): + def _test_alpha_premult( + self, op: Callable[[Image.Image, tuple[int, int]], Image.Image] + ) -> None: # create image with half white, half black, # with the black half transparent. # do op, @@ -150,14 +161,14 @@ class TestImageTransform: hist = im_background.histogram() assert 40 * 10 == hist[-1] - def test_alpha_premult_resize(self): - def op(im, sz): + def test_alpha_premult_resize(self) -> None: + def op(im: Image.Image, sz: tuple[int, int]) -> Image.Image: return im.resize(sz, Image.Resampling.BILINEAR) self._test_alpha_premult(op) - def test_alpha_premult_transform(self): - def op(im, sz): + def test_alpha_premult_transform(self) -> None: + def op(im: Image.Image, sz: tuple[int, int]) -> Image.Image: (w, h) = im.size return im.transform( sz, Image.Transform.EXTENT, (0, 0, w, h), Image.Resampling.BILINEAR @@ -165,7 +176,9 @@ class TestImageTransform: self._test_alpha_premult(op) - def _test_nearest(self, op, mode): + def _test_nearest( + self, op: Callable[[Image.Image, tuple[int, int]], Image.Image], mode: str + ) -> None: # create white image with half transparent, # do op, # the image should remain white with half transparent @@ -188,15 +201,15 @@ class TestImageTransform: ) @pytest.mark.parametrize("mode", ("RGBA", "LA")) - def test_nearest_resize(self, mode): - def op(im, sz): + def test_nearest_resize(self, mode: str) -> None: + def op(im: Image.Image, sz: tuple[int, int]) -> Image.Image: return im.resize(sz, Image.Resampling.NEAREST) self._test_nearest(op, mode) @pytest.mark.parametrize("mode", ("RGBA", "LA")) - def test_nearest_transform(self, mode): - def op(im, sz): + def test_nearest_transform(self, mode: str) -> None: + def op(im: Image.Image, sz: tuple[int, int]) -> Image.Image: (w, h) = im.size return im.transform( sz, Image.Transform.EXTENT, (0, 0, w, h), Image.Resampling.NEAREST @@ -204,7 +217,7 @@ class TestImageTransform: self._test_nearest(op, mode) - def test_blank_fill(self): + def test_blank_fill(self) -> None: # attempting to hit # https://github.com/python-pillow/Pillow/issues/254 reported # @@ -219,20 +232,22 @@ class TestImageTransform: # Running by default, but I'd totally understand not doing it in # the future - pattern = [Image.new("RGBA", (1024, 1024), (a, a, a, a)) for a in range(1, 65)] + pattern: list[Image.Image] | None = [ + Image.new("RGBA", (1024, 1024), (a, a, a, a)) for a in range(1, 65) + ] # Yeah. Watch some JIT optimize this out. pattern = None # noqa: F841 self.test_mesh() - def test_missing_method_data(self): + def test_missing_method_data(self) -> None: with hopper() as im: with pytest.raises(ValueError): im.transform((100, 100), None) @pytest.mark.parametrize("resample", (Image.Resampling.BOX, "unknown")) - def test_unknown_resampling_filter(self, resample): + def test_unknown_resampling_filter(self, resample: Image.Resampling | str) -> None: with hopper() as im: (w, h) = im.size with pytest.raises(ValueError): @@ -242,7 +257,7 @@ class TestImageTransform: class TestImageTransformAffine: transform = Image.Transform.AFFINE - def _test_image(self): + def _test_image(self) -> Image.Image: im = hopper("RGB") return im.crop((10, 20, im.width - 10, im.height - 20)) @@ -255,7 +270,7 @@ class TestImageTransformAffine: (270, Image.Transpose.ROTATE_270), ), ) - def test_rotate(self, deg, transpose): + def test_rotate(self, deg: int, transpose: Image.Transpose | None) -> None: im = self._test_image() angle = -math.radians(deg) @@ -305,7 +320,13 @@ class TestImageTransformAffine: (Image.Resampling.BICUBIC, 1), ), ) - def test_resize(self, scale, epsilon_scale, resample, epsilon): + def test_resize( + self, + scale: float, + epsilon_scale: float, + resample: Image.Resampling, + epsilon: int, + ) -> None: im = self._test_image() size_up = int(round(im.width * scale)), int(round(im.height * scale)) @@ -334,7 +355,14 @@ class TestImageTransformAffine: (Image.Resampling.BICUBIC, 1), ), ) - def test_translate(self, x, y, epsilon_scale, resample, epsilon): + def test_translate( + self, + x: float, + y: float, + epsilon_scale: float, + resample: Image.Resampling, + epsilon: float, + ) -> None: im = self._test_image() size_up = int(round(im.width + x)), int(round(im.height + y)) diff --git a/Tests/test_image_transpose.py b/Tests/test_image_transpose.py index 66a2d9e29..d384d8141 100644 --- a/Tests/test_image_transpose.py +++ b/Tests/test_image_transpose.py @@ -1,6 +1,8 @@ from __future__ import annotations + import pytest +from PIL import Image from PIL.Image import Transpose from . import helper @@ -13,7 +15,7 @@ HOPPER = { @pytest.mark.parametrize("mode", HOPPER) -def test_flip_left_right(mode): +def test_flip_left_right(mode: str) -> None: im = HOPPER[mode] out = im.transpose(Transpose.FLIP_LEFT_RIGHT) assert out.mode == mode @@ -27,7 +29,7 @@ def test_flip_left_right(mode): @pytest.mark.parametrize("mode", HOPPER) -def test_flip_top_bottom(mode): +def test_flip_top_bottom(mode: str) -> None: im = HOPPER[mode] out = im.transpose(Transpose.FLIP_TOP_BOTTOM) assert out.mode == mode @@ -41,7 +43,7 @@ def test_flip_top_bottom(mode): @pytest.mark.parametrize("mode", HOPPER) -def test_rotate_90(mode): +def test_rotate_90(mode: str) -> None: im = HOPPER[mode] out = im.transpose(Transpose.ROTATE_90) assert out.mode == mode @@ -55,7 +57,7 @@ def test_rotate_90(mode): @pytest.mark.parametrize("mode", HOPPER) -def test_rotate_180(mode): +def test_rotate_180(mode: str) -> None: im = HOPPER[mode] out = im.transpose(Transpose.ROTATE_180) assert out.mode == mode @@ -69,7 +71,7 @@ def test_rotate_180(mode): @pytest.mark.parametrize("mode", HOPPER) -def test_rotate_270(mode): +def test_rotate_270(mode: str) -> None: im = HOPPER[mode] out = im.transpose(Transpose.ROTATE_270) assert out.mode == mode @@ -83,7 +85,7 @@ def test_rotate_270(mode): @pytest.mark.parametrize("mode", HOPPER) -def test_transpose(mode): +def test_transpose(mode: str) -> None: im = HOPPER[mode] out = im.transpose(Transpose.TRANSPOSE) assert out.mode == mode @@ -97,7 +99,7 @@ def test_transpose(mode): @pytest.mark.parametrize("mode", HOPPER) -def test_tranverse(mode): +def test_tranverse(mode: str) -> None: im = HOPPER[mode] out = im.transpose(Transpose.TRANSVERSE) assert out.mode == mode @@ -111,10 +113,10 @@ def test_tranverse(mode): @pytest.mark.parametrize("mode", HOPPER) -def test_roundtrip(mode): +def test_roundtrip(mode: str) -> None: im = HOPPER[mode] - def transpose(first, second): + def transpose(first: Transpose, second: Transpose) -> Image.Image: return im.transpose(first).transpose(second) assert_image_equal( diff --git a/Tests/test_imagechops.py b/Tests/test_imagechops.py index 8e3a738d7..7e2290c15 100644 --- a/Tests/test_imagechops.py +++ b/Tests/test_imagechops.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from typing import Callable + from PIL import Image, ImageChops from .helper import assert_image_equal, hopper @@ -14,7 +17,7 @@ WHITE = (255, 255, 255) GRAY = 128 -def test_sanity(): +def test_sanity() -> None: im = hopper("L") ImageChops.constant(im, 128) @@ -47,7 +50,7 @@ def test_sanity(): ImageChops.offset(im, 10, 20) -def test_add(): +def test_add() -> None: # Arrange with Image.open("Tests/images/imagedraw_ellipse_RGB.png") as im1: with Image.open("Tests/images/imagedraw_floodfill_RGB.png") as im2: @@ -59,7 +62,7 @@ def test_add(): assert new.getpixel((50, 50)) == ORANGE -def test_add_scale_offset(): +def test_add_scale_offset() -> None: # Arrange with Image.open("Tests/images/imagedraw_ellipse_RGB.png") as im1: with Image.open("Tests/images/imagedraw_floodfill_RGB.png") as im2: @@ -71,7 +74,7 @@ def test_add_scale_offset(): assert new.getpixel((50, 50)) == (202, 151, 100) -def test_add_clip(): +def test_add_clip() -> None: # Arrange im = hopper() @@ -82,7 +85,7 @@ def test_add_clip(): assert new.getpixel((50, 50)) == (255, 255, 254) -def test_add_modulo(): +def test_add_modulo() -> None: # Arrange with Image.open("Tests/images/imagedraw_ellipse_RGB.png") as im1: with Image.open("Tests/images/imagedraw_floodfill_RGB.png") as im2: @@ -94,7 +97,7 @@ def test_add_modulo(): assert new.getpixel((50, 50)) == ORANGE -def test_add_modulo_no_clip(): +def test_add_modulo_no_clip() -> None: # Arrange im = hopper() @@ -105,7 +108,7 @@ def test_add_modulo_no_clip(): assert new.getpixel((50, 50)) == (224, 76, 254) -def test_blend(): +def test_blend() -> None: # Arrange with Image.open("Tests/images/imagedraw_ellipse_RGB.png") as im1: with Image.open("Tests/images/imagedraw_floodfill_RGB.png") as im2: @@ -117,7 +120,7 @@ def test_blend(): assert new.getpixel((50, 50)) == BROWN -def test_constant(): +def test_constant() -> None: # Arrange im = Image.new("RGB", (20, 10)) @@ -130,7 +133,7 @@ def test_constant(): assert new.getpixel((19, 9)) == GRAY -def test_darker_image(): +def test_darker_image() -> None: # Arrange with Image.open("Tests/images/imagedraw_chord_RGB.png") as im1: with Image.open("Tests/images/imagedraw_outline_chord_RGB.png") as im2: @@ -141,7 +144,7 @@ def test_darker_image(): assert_image_equal(new, im2) -def test_darker_pixel(): +def test_darker_pixel() -> None: # Arrange im1 = hopper() with Image.open("Tests/images/imagedraw_chord_RGB.png") as im2: @@ -152,7 +155,7 @@ def test_darker_pixel(): assert new.getpixel((50, 50)) == (240, 166, 0) -def test_difference(): +def test_difference() -> None: # Arrange with Image.open("Tests/images/imagedraw_arc_end_le_start.png") as im1: with Image.open("Tests/images/imagedraw_arc_no_loops.png") as im2: @@ -163,7 +166,7 @@ def test_difference(): assert new.getbbox() == (25, 25, 76, 76) -def test_difference_pixel(): +def test_difference_pixel() -> None: # Arrange im1 = hopper() with Image.open("Tests/images/imagedraw_polygon_kite_RGB.png") as im2: @@ -174,7 +177,7 @@ def test_difference_pixel(): assert new.getpixel((50, 50)) == (240, 166, 128) -def test_duplicate(): +def test_duplicate() -> None: # Arrange im = hopper() @@ -185,7 +188,7 @@ def test_duplicate(): assert_image_equal(new, im) -def test_invert(): +def test_invert() -> None: # Arrange with Image.open("Tests/images/imagedraw_floodfill_RGB.png") as im: # Act @@ -197,7 +200,7 @@ def test_invert(): assert new.getpixel((50, 50)) == CYAN -def test_lighter_image(): +def test_lighter_image() -> None: # Arrange with Image.open("Tests/images/imagedraw_chord_RGB.png") as im1: with Image.open("Tests/images/imagedraw_outline_chord_RGB.png") as im2: @@ -208,7 +211,7 @@ def test_lighter_image(): assert_image_equal(new, im1) -def test_lighter_pixel(): +def test_lighter_pixel() -> None: # Arrange im1 = hopper() with Image.open("Tests/images/imagedraw_chord_RGB.png") as im2: @@ -219,7 +222,7 @@ def test_lighter_pixel(): assert new.getpixel((50, 50)) == (255, 255, 127) -def test_multiply_black(): +def test_multiply_black() -> None: """If you multiply an image with a solid black image, the result is black.""" # Arrange @@ -233,7 +236,7 @@ def test_multiply_black(): assert_image_equal(new, black) -def test_multiply_green(): +def test_multiply_green() -> None: # Arrange with Image.open("Tests/images/imagedraw_floodfill_RGB.png") as im: green = Image.new("RGB", im.size, "green") @@ -247,7 +250,7 @@ def test_multiply_green(): assert new.getpixel((50, 50)) == BLACK -def test_multiply_white(): +def test_multiply_white() -> None: """If you multiply with a solid white image, the image is unaffected.""" # Arrange im1 = hopper() @@ -260,7 +263,7 @@ def test_multiply_white(): assert_image_equal(new, im1) -def test_offset(): +def test_offset() -> None: # Arrange xoffset = 45 yoffset = 20 @@ -277,7 +280,7 @@ def test_offset(): assert ImageChops.offset(im, xoffset) == ImageChops.offset(im, xoffset, xoffset) -def test_screen(): +def test_screen() -> None: # Arrange with Image.open("Tests/images/imagedraw_ellipse_RGB.png") as im1: with Image.open("Tests/images/imagedraw_floodfill_RGB.png") as im2: @@ -289,7 +292,7 @@ def test_screen(): assert new.getpixel((50, 50)) == ORANGE -def test_subtract(): +def test_subtract() -> None: # Arrange with Image.open("Tests/images/imagedraw_chord_RGB.png") as im1: with Image.open("Tests/images/imagedraw_outline_chord_RGB.png") as im2: @@ -302,7 +305,7 @@ def test_subtract(): assert new.getpixel((50, 52)) == BLACK -def test_subtract_scale_offset(): +def test_subtract_scale_offset() -> None: # Arrange with Image.open("Tests/images/imagedraw_chord_RGB.png") as im1: with Image.open("Tests/images/imagedraw_outline_chord_RGB.png") as im2: @@ -314,7 +317,7 @@ def test_subtract_scale_offset(): assert new.getpixel((50, 50)) == (100, 202, 100) -def test_subtract_clip(): +def test_subtract_clip() -> None: # Arrange im1 = hopper() with Image.open("Tests/images/imagedraw_chord_RGB.png") as im2: @@ -325,7 +328,7 @@ def test_subtract_clip(): assert new.getpixel((50, 50)) == (0, 0, 127) -def test_subtract_modulo(): +def test_subtract_modulo() -> None: # Arrange with Image.open("Tests/images/imagedraw_chord_RGB.png") as im1: with Image.open("Tests/images/imagedraw_outline_chord_RGB.png") as im2: @@ -338,7 +341,7 @@ def test_subtract_modulo(): assert new.getpixel((50, 52)) == BLACK -def test_subtract_modulo_no_clip(): +def test_subtract_modulo_no_clip() -> None: # Arrange im1 = hopper() with Image.open("Tests/images/imagedraw_chord_RGB.png") as im2: @@ -349,7 +352,7 @@ def test_subtract_modulo_no_clip(): assert new.getpixel((50, 50)) == (241, 167, 127) -def test_soft_light(): +def test_soft_light() -> None: # Arrange with Image.open("Tests/images/hopper.png") as im1: with Image.open("Tests/images/hopper-XYZ.png") as im2: @@ -361,7 +364,7 @@ def test_soft_light(): assert new.getpixel((15, 100)) == (1, 1, 3) -def test_hard_light(): +def test_hard_light() -> None: # Arrange with Image.open("Tests/images/hopper.png") as im1: with Image.open("Tests/images/hopper-XYZ.png") as im2: @@ -373,7 +376,7 @@ def test_hard_light(): assert new.getpixel((15, 100)) == (1, 1, 2) -def test_overlay(): +def test_overlay() -> None: # Arrange with Image.open("Tests/images/hopper.png") as im1: with Image.open("Tests/images/hopper-XYZ.png") as im2: @@ -385,8 +388,10 @@ def test_overlay(): assert new.getpixel((15, 100)) == (1, 1, 2) -def test_logical(): - def table(op, a, b): +def test_logical() -> None: + def table( + op: Callable[[Image.Image, Image.Image], Image.Image], a: int, b: int + ) -> tuple[int, int, int, int]: out = [] for x in (a, b): imx = Image.new("1", (1, 1), x) diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 0dde82bd7..6be29a70f 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -1,9 +1,12 @@ from __future__ import annotations + import datetime import os import re import shutil from io import BytesIO +from pathlib import Path +from typing import Any import pytest @@ -31,7 +34,7 @@ SRGB = "Tests/icc/sRGB_IEC61966-2-1_black_scaled.icc" HAVE_PROFILE = os.path.exists(SRGB) -def setup_module(): +def setup_module() -> None: try: from PIL import ImageCms @@ -41,16 +44,16 @@ def setup_module(): pytest.skip(str(v)) -def skip_missing(): +def skip_missing() -> None: if not HAVE_PROFILE: pytest.skip("SRGB profile not available") -def test_sanity(): +def test_sanity() -> None: # basic smoke test. # this mostly follows the cms_test outline. - - v = ImageCms.versions() # should return four strings + with pytest.warns(DeprecationWarning): + v = ImageCms.versions() # should return four strings assert v[0] == "1.0.0 pil" assert list(map(type, v)) == [str, str, str, str] @@ -90,7 +93,17 @@ def test_sanity(): hopper().point(t) -def test_name(): +def test_flags() -> None: + assert ImageCms.Flags.NONE == 0 + assert ImageCms.Flags.GRIDPOINTS(0) == ImageCms.Flags.NONE + assert ImageCms.Flags.GRIDPOINTS(256) == ImageCms.Flags.NONE + + assert ImageCms.Flags.GRIDPOINTS(255) == (255 << 16) + assert ImageCms.Flags.GRIDPOINTS(-1) == ImageCms.Flags.GRIDPOINTS(255) + assert ImageCms.Flags.GRIDPOINTS(511) == ImageCms.Flags.GRIDPOINTS(255) + + +def test_name() -> None: skip_missing() # get profile information for file assert ( @@ -99,7 +112,7 @@ def test_name(): ) -def test_info(): +def test_info() -> None: skip_missing() assert ImageCms.getProfileInfo(SRGB).splitlines() == [ "sRGB IEC61966-2-1 black scaled", @@ -109,7 +122,7 @@ def test_info(): ] -def test_copyright(): +def test_copyright() -> None: skip_missing() assert ( ImageCms.getProfileCopyright(SRGB).strip() @@ -117,12 +130,12 @@ def test_copyright(): ) -def test_manufacturer(): +def test_manufacturer() -> None: skip_missing() assert ImageCms.getProfileManufacturer(SRGB).strip() == "" -def test_model(): +def test_model() -> None: skip_missing() assert ( ImageCms.getProfileModel(SRGB).strip() @@ -130,14 +143,14 @@ def test_model(): ) -def test_description(): +def test_description() -> None: skip_missing() assert ( ImageCms.getProfileDescription(SRGB).strip() == "sRGB IEC61966-2-1 black scaled" ) -def test_intent(): +def test_intent() -> None: skip_missing() assert ImageCms.getDefaultIntent(SRGB) == 0 support = ImageCms.isIntentSupported( @@ -146,7 +159,7 @@ def test_intent(): assert support == 1 -def test_profile_object(): +def test_profile_object() -> None: # same, using profile object p = ImageCms.createProfile("sRGB") # assert ImageCms.getProfileName(p).strip() == "sRGB built-in - (lcms internal)" @@ -159,7 +172,7 @@ def test_profile_object(): assert support == 1 -def test_extensions(): +def test_extensions() -> None: # extensions with Image.open("Tests/images/rgb.jpg") as i: @@ -170,7 +183,7 @@ def test_extensions(): ) -def test_exceptions(): +def test_exceptions() -> None: # Test mode mismatch psRGB = ImageCms.createProfile("sRGB") pLab = ImageCms.createProfile("LAB") @@ -196,17 +209,17 @@ def test_exceptions(): ImageCms.isIntentSupported(SRGB, None, None) -def test_display_profile(): +def test_display_profile() -> None: # try fetching the profile for the current display device ImageCms.get_display_profile() -def test_lab_color_profile(): +def test_lab_color_profile() -> None: ImageCms.createProfile("LAB", 5000) ImageCms.createProfile("LAB", 6500) -def test_unsupported_color_space(): +def test_unsupported_color_space() -> None: with pytest.raises( ImageCms.PyCMSError, match=re.escape( @@ -216,7 +229,7 @@ def test_unsupported_color_space(): ImageCms.createProfile("unsupported") -def test_invalid_color_temperature(): +def test_invalid_color_temperature() -> None: with pytest.raises( ImageCms.PyCMSError, match='Color temperature must be numeric, "invalid" not valid', @@ -225,7 +238,7 @@ def test_invalid_color_temperature(): @pytest.mark.parametrize("flag", ("my string", -1)) -def test_invalid_flag(flag): +def test_invalid_flag(flag: str | int) -> None: with hopper() as im: with pytest.raises( ImageCms.PyCMSError, match="flags must be an integer between 0 and " @@ -233,7 +246,7 @@ def test_invalid_flag(flag): ImageCms.profileToProfile(im, "foo", "bar", flags=flag) -def test_simple_lab(): +def test_simple_lab() -> None: i = Image.new("RGB", (10, 10), (128, 128, 128)) psRGB = ImageCms.createProfile("sRGB") @@ -257,7 +270,7 @@ def test_simple_lab(): assert list(b_data) == [128] * 100 -def test_lab_color(): +def test_lab_color() -> None: psRGB = ImageCms.createProfile("sRGB") pLab = ImageCms.createProfile("LAB") t = ImageCms.buildTransform(psRGB, pLab, "RGB", "LAB") @@ -272,7 +285,7 @@ def test_lab_color(): assert_image_similar_tofile(i, "Tests/images/hopper.Lab.tif", 3.5) -def test_lab_srgb(): +def test_lab_srgb() -> None: psRGB = ImageCms.createProfile("sRGB") pLab = ImageCms.createProfile("LAB") t = ImageCms.buildTransform(pLab, psRGB, "LAB", "RGB") @@ -289,7 +302,7 @@ def test_lab_srgb(): assert "sRGB" in ImageCms.getProfileDescription(profile) -def test_lab_roundtrip(): +def test_lab_roundtrip() -> None: # check to see if we're at least internally consistent. psRGB = ImageCms.createProfile("sRGB") pLab = ImageCms.createProfile("LAB") @@ -306,7 +319,7 @@ def test_lab_roundtrip(): assert_image_similar(hopper(), out, 2) -def test_profile_tobytes(): +def test_profile_tobytes() -> None: with Image.open("Tests/images/rgb.jpg") as i: p = ImageCms.getOpenProfile(BytesIO(i.info["icc_profile"])) @@ -318,22 +331,26 @@ def test_profile_tobytes(): assert ImageCms.getProfileDescription(p) == ImageCms.getProfileDescription(p2) -def test_extended_information(): +def test_extended_information() -> None: skip_missing() o = ImageCms.getOpenProfile(SRGB) p = o.profile - def assert_truncated_tuple_equal(tup1, tup2, digits=10): + def assert_truncated_tuple_equal( + tup1: tuple[Any, ...], tup2: tuple[Any, ...], digits: int = 10 + ) -> None: # Helper function to reduce precision of tuples of floats # recursively and then check equality. power = 10**digits - def truncate_tuple(tuple_or_float): + def truncate_tuple(tuple_value: tuple[Any, ...]) -> tuple[Any, ...]: return tuple( - truncate_tuple(val) - if isinstance(val, tuple) - else int(val * power) / power - for val in tuple_or_float + ( + truncate_tuple(val) + if isinstance(val, tuple) + else int(val * power) / power + ) + for val in tuple_value ) assert truncate_tuple(tup1) == truncate_tuple(tup2) @@ -465,7 +482,7 @@ def test_extended_information(): assert p.xcolor_space == "RGB " -def test_non_ascii_path(tmp_path): +def test_non_ascii_path(tmp_path: Path) -> None: skip_missing() tempfile = str(tmp_path / ("temp_" + chr(128) + ".icc")) try: @@ -478,7 +495,7 @@ def test_non_ascii_path(tmp_path): assert p.model == "IEC 61966-2-1 Default RGB Colour Space - sRGB" -def test_profile_typesafety(): +def test_profile_typesafety() -> None: """Profile init type safety prepatch, these would segfault, postpatch they should emit a typeerror @@ -490,8 +507,10 @@ def test_profile_typesafety(): ImageCms.ImageCmsProfile(1).tobytes() -def assert_aux_channel_preserved(mode, transform_in_place, preserved_channel): - def create_test_image(): +def assert_aux_channel_preserved( + mode: str, transform_in_place: bool, preserved_channel: str +) -> None: + def create_test_image() -> Image.Image: # set up test image with something interesting in the tested aux channel. # fmt: off nine_grid_deltas = [ @@ -545,31 +564,31 @@ def assert_aux_channel_preserved(mode, transform_in_place, preserved_channel): assert_image_equal(source_image_aux, result_image_aux) -def test_preserve_auxiliary_channels_rgba(): +def test_preserve_auxiliary_channels_rgba() -> None: assert_aux_channel_preserved( mode="RGBA", transform_in_place=False, preserved_channel="A" ) -def test_preserve_auxiliary_channels_rgba_in_place(): +def test_preserve_auxiliary_channels_rgba_in_place() -> None: assert_aux_channel_preserved( mode="RGBA", transform_in_place=True, preserved_channel="A" ) -def test_preserve_auxiliary_channels_rgbx(): +def test_preserve_auxiliary_channels_rgbx() -> None: assert_aux_channel_preserved( mode="RGBX", transform_in_place=False, preserved_channel="X" ) -def test_preserve_auxiliary_channels_rgbx_in_place(): +def test_preserve_auxiliary_channels_rgbx_in_place() -> None: assert_aux_channel_preserved( mode="RGBX", transform_in_place=True, preserved_channel="X" ) -def test_auxiliary_channels_isolated(): +def test_auxiliary_channels_isolated() -> None: # test data in aux channels does not affect non-aux channels aux_channel_formats = [ # format, profile, color-only format, source test image @@ -619,7 +638,7 @@ def test_auxiliary_channels_isolated(): @pytest.mark.parametrize("mode", ("RGB", "RGBA", "RGBX")) -def test_rgb_lab(mode): +def test_rgb_lab(mode: str) -> None: im = Image.new(mode, (1, 1)) converted_im = im.convert("LAB") assert converted_im.getpixel((0, 0)) == (0, 128, 128) @@ -627,3 +646,12 @@ def test_rgb_lab(mode): im = Image.new("LAB", (1, 1), (255, 0, 0)) converted_im = im.convert(mode) assert converted_im.getpixel((0, 0))[:3] == (0, 255, 255) + + +def test_deprecation() -> None: + with pytest.warns(DeprecationWarning): + assert ImageCms.DESCRIPTION.strip().startswith("pyCMS") + with pytest.warns(DeprecationWarning): + assert ImageCms.VERSION == "1.0.0 pil" + with pytest.warns(DeprecationWarning): + assert isinstance(ImageCms.FLAGS, dict) diff --git a/Tests/test_imagecolor.py b/Tests/test_imagecolor.py index c0ffd2ebf..6eea7886d 100644 --- a/Tests/test_imagecolor.py +++ b/Tests/test_imagecolor.py @@ -1,10 +1,11 @@ from __future__ import annotations + import pytest from PIL import Image, ImageColor -def test_hash(): +def test_hash() -> None: # short 3 components assert (255, 0, 0) == ImageColor.getrgb("#f00") assert (0, 255, 0) == ImageColor.getrgb("#0f0") @@ -56,7 +57,7 @@ def test_hash(): ImageColor.getrgb("#f00000 ") -def test_colormap(): +def test_colormap() -> None: assert (0, 0, 0) == ImageColor.getrgb("black") assert (255, 255, 255) == ImageColor.getrgb("white") assert (255, 255, 255) == ImageColor.getrgb("WHITE") @@ -65,7 +66,7 @@ def test_colormap(): ImageColor.getrgb("black ") -def test_functions(): +def test_functions() -> None: # rgb numbers assert (255, 0, 0) == ImageColor.getrgb("rgb(255,0,0)") assert (0, 255, 0) == ImageColor.getrgb("rgb(0,255,0)") @@ -159,7 +160,7 @@ def test_functions(): # look for rounding errors (based on code by Tim Hatch) -def test_rounding_errors(): +def test_rounding_errors() -> None: for color in ImageColor.colormap: expected = Image.new("RGB", (1, 1), color).convert("L").getpixel((0, 0)) actual = ImageColor.getcolor(color, "L") @@ -194,11 +195,11 @@ def test_rounding_errors(): Image.new("LA", (1, 1), "white") -def test_color_hsv(): +def test_color_hsv() -> None: assert (170, 255, 255) == ImageColor.getcolor("hsv(240, 100%, 100%)", "HSV") -def test_color_too_long(): +def test_color_too_long() -> None: # Arrange color_too_long = "hsl(" + "1" * 40 + "," + "1" * 40 + "%," + "1" * 40 + "%)" diff --git a/Tests/test_imagedraw.py b/Tests/test_imagedraw.py index 379fe78cd..274753c6c 100644 --- a/Tests/test_imagedraw.py +++ b/Tests/test_imagedraw.py @@ -1,10 +1,12 @@ from __future__ import annotations + import contextlib import os.path import pytest from PIL import Image, ImageColor, ImageDraw, ImageFont, features +from PIL._typing import Coords from .helper import ( assert_image_equal, @@ -46,7 +48,7 @@ KITE_POINTS = ( ) -def test_sanity(): +def test_sanity() -> None: im = hopper("RGB").copy() draw = ImageDraw.ImageDraw(im) @@ -58,13 +60,13 @@ def test_sanity(): draw.rectangle(list(range(4))) -def test_valueerror(): +def test_valueerror() -> None: with Image.open("Tests/images/chi.gif") as im: draw = ImageDraw.Draw(im) draw.line((0, 0), fill=(0, 0, 0)) -def test_mode_mismatch(): +def test_mode_mismatch() -> None: im = hopper("RGB").copy() with pytest.raises(ValueError): @@ -73,7 +75,7 @@ def test_mode_mismatch(): @pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("start, end", ((0, 180), (0.5, 180.4))) -def test_arc(bbox, start, end): +def test_arc(bbox: Coords, start: float, end: float) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -86,7 +88,7 @@ def test_arc(bbox, start, end): @pytest.mark.parametrize("bbox", BBOX) -def test_arc_end_le_start(bbox): +def test_arc_end_le_start(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -101,7 +103,7 @@ def test_arc_end_le_start(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_arc_no_loops(bbox): +def test_arc_no_loops(bbox: Coords) -> None: # No need to go in loops # Arrange im = Image.new("RGB", (W, H)) @@ -117,7 +119,7 @@ def test_arc_no_loops(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width(bbox): +def test_arc_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -130,7 +132,7 @@ def test_arc_width(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width_pieslice_large(bbox): +def test_arc_width_pieslice_large(bbox: Coords) -> None: # Tests an arc with a large enough width that it is a pieslice # Arrange im = Image.new("RGB", (W, H)) @@ -144,7 +146,7 @@ def test_arc_width_pieslice_large(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width_fill(bbox): +def test_arc_width_fill(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -157,7 +159,7 @@ def test_arc_width_fill(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_arc_width_non_whole_angle(bbox): +def test_arc_width_non_whole_angle(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -170,7 +172,7 @@ def test_arc_width_non_whole_angle(bbox): assert_image_similar_tofile(im, expected, 1) -def test_arc_high(): +def test_arc_high() -> None: # Arrange im = Image.new("RGB", (200, 200)) draw = ImageDraw.Draw(im) @@ -183,7 +185,7 @@ def test_arc_high(): assert_image_equal_tofile(im, "Tests/images/imagedraw_arc_high.png") -def test_bitmap(): +def test_bitmap() -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -199,7 +201,7 @@ def test_bitmap(): @pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("bbox", BBOX) -def test_chord(mode, bbox): +def test_chord(mode: str, bbox: Coords) -> None: # Arrange im = Image.new(mode, (W, H)) draw = ImageDraw.Draw(im) @@ -213,7 +215,7 @@ def test_chord(mode, bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_chord_width(bbox): +def test_chord_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -226,7 +228,7 @@ def test_chord_width(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_chord_width_fill(bbox): +def test_chord_width_fill(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -239,7 +241,7 @@ def test_chord_width_fill(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_chord_zero_width(bbox): +def test_chord_zero_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -251,7 +253,7 @@ def test_chord_zero_width(bbox): assert_image_equal_tofile(im, "Tests/images/imagedraw_chord_zero_width.png") -def test_chord_too_fat(): +def test_chord_too_fat() -> None: # Arrange im = Image.new("RGB", (100, 100)) draw = ImageDraw.Draw(im) @@ -265,7 +267,7 @@ def test_chord_too_fat(): @pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse(mode, bbox): +def test_ellipse(mode: str, bbox: Coords) -> None: # Arrange im = Image.new(mode, (W, H)) draw = ImageDraw.Draw(im) @@ -279,7 +281,7 @@ def test_ellipse(mode, bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_translucent(bbox): +def test_ellipse_translucent(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im, "RGBA") @@ -292,7 +294,7 @@ def test_ellipse_translucent(bbox): assert_image_similar_tofile(im, expected, 1) -def test_ellipse_edge(): +def test_ellipse_edge() -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -304,7 +306,7 @@ def test_ellipse_edge(): assert_image_similar_tofile(im, "Tests/images/imagedraw_ellipse_edge.png", 1) -def test_ellipse_symmetric(): +def test_ellipse_symmetric() -> None: for width, bbox in ( (100, (24, 24, 75, 75)), (101, (25, 25, 75, 75)), @@ -316,7 +318,7 @@ def test_ellipse_symmetric(): @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_width(bbox): +def test_ellipse_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -328,7 +330,7 @@ def test_ellipse_width(bbox): assert_image_similar_tofile(im, "Tests/images/imagedraw_ellipse_width.png", 1) -def test_ellipse_width_large(): +def test_ellipse_width_large() -> None: # Arrange im = Image.new("RGB", (500, 500)) draw = ImageDraw.Draw(im) @@ -341,7 +343,7 @@ def test_ellipse_width_large(): @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_width_fill(bbox): +def test_ellipse_width_fill(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -354,7 +356,7 @@ def test_ellipse_width_fill(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse_zero_width(bbox): +def test_ellipse_zero_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -366,7 +368,7 @@ def test_ellipse_zero_width(bbox): assert_image_equal_tofile(im, "Tests/images/imagedraw_ellipse_zero_width.png") -def ellipse_various_sizes_helper(filled): +def ellipse_various_sizes_helper(filled: bool) -> Image.Image: ellipse_sizes = range(32) image_size = sum(ellipse_sizes) + len(ellipse_sizes) + 1 im = Image.new("RGB", (image_size, image_size)) @@ -393,13 +395,13 @@ def ellipse_various_sizes_helper(filled): return im -def test_ellipse_various_sizes(): +def test_ellipse_various_sizes() -> None: im = ellipse_various_sizes_helper(False) assert_image_equal_tofile(im, "Tests/images/imagedraw_ellipse_various_sizes.png") -def test_ellipse_various_sizes_filled(): +def test_ellipse_various_sizes_filled() -> None: im = ellipse_various_sizes_helper(True) assert_image_equal_tofile( @@ -408,7 +410,7 @@ def test_ellipse_various_sizes_filled(): @pytest.mark.parametrize("points", POINTS) -def test_line(points): +def test_line(points: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -420,7 +422,7 @@ def test_line(points): assert_image_equal_tofile(im, "Tests/images/imagedraw_line.png") -def test_shape1(): +def test_shape1() -> None: # Arrange im = Image.new("RGB", (100, 100), "white") draw = ImageDraw.Draw(im) @@ -441,7 +443,7 @@ def test_shape1(): assert_image_equal_tofile(im, "Tests/images/imagedraw_shape1.png") -def test_shape2(): +def test_shape2() -> None: # Arrange im = Image.new("RGB", (100, 100), "white") draw = ImageDraw.Draw(im) @@ -462,7 +464,7 @@ def test_shape2(): assert_image_equal_tofile(im, "Tests/images/imagedraw_shape2.png") -def test_transform(): +def test_transform() -> None: # Arrange im = Image.new("RGB", (100, 100), "white") expected = im.copy() @@ -481,7 +483,7 @@ def test_transform(): @pytest.mark.parametrize("bbox", BBOX) @pytest.mark.parametrize("start, end", ((-92, 46), (-92.2, 46.2))) -def test_pieslice(bbox, start, end): +def test_pieslice(bbox: Coords, start: float, end: float) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -494,7 +496,7 @@ def test_pieslice(bbox, start, end): @pytest.mark.parametrize("bbox", BBOX) -def test_pieslice_width(bbox): +def test_pieslice_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -507,7 +509,7 @@ def test_pieslice_width(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_pieslice_width_fill(bbox): +def test_pieslice_width_fill(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -521,7 +523,7 @@ def test_pieslice_width_fill(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_pieslice_zero_width(bbox): +def test_pieslice_zero_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -533,7 +535,7 @@ def test_pieslice_zero_width(bbox): assert_image_equal_tofile(im, "Tests/images/imagedraw_pieslice_zero_width.png") -def test_pieslice_wide(): +def test_pieslice_wide() -> None: # Arrange im = Image.new("RGB", (200, 100)) draw = ImageDraw.Draw(im) @@ -545,7 +547,7 @@ def test_pieslice_wide(): assert_image_equal_tofile(im, "Tests/images/imagedraw_pieslice_wide.png") -def test_pieslice_no_spikes(): +def test_pieslice_no_spikes() -> None: im = Image.new("RGB", (161, 161), "white") draw = ImageDraw.Draw(im) cxs = ( @@ -576,7 +578,7 @@ def test_pieslice_no_spikes(): @pytest.mark.parametrize("points", POINTS) -def test_point(points): +def test_point(points: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -588,7 +590,7 @@ def test_point(points): assert_image_equal_tofile(im, "Tests/images/imagedraw_point.png") -def test_point_I16(): +def test_point_I16() -> None: # Arrange im = Image.new("I;16", (1, 1)) draw = ImageDraw.Draw(im) @@ -601,7 +603,7 @@ def test_point_I16(): @pytest.mark.parametrize("points", POINTS) -def test_polygon(points): +def test_polygon(points: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -615,7 +617,9 @@ def test_polygon(points): @pytest.mark.parametrize("mode", ("RGB", "L")) @pytest.mark.parametrize("kite_points", KITE_POINTS) -def test_polygon_kite(mode, kite_points): +def test_polygon_kite( + mode: str, kite_points: tuple[tuple[int, int], ...] | list[tuple[int, int]] +) -> None: # Test drawing lines of different gradients (dx>dy, dy>dx) and # vertical (dx==0) and horizontal (dy==0) lines # Arrange @@ -630,7 +634,7 @@ def test_polygon_kite(mode, kite_points): assert_image_equal_tofile(im, expected) -def test_polygon_1px_high(): +def test_polygon_1px_high() -> None: # Test drawing a 1px high polygon # Arrange im = Image.new("RGB", (3, 3)) @@ -644,7 +648,7 @@ def test_polygon_1px_high(): assert_image_equal_tofile(im, expected) -def test_polygon_1px_high_translucent(): +def test_polygon_1px_high_translucent() -> None: # Test drawing a translucent 1px high polygon # Arrange im = Image.new("RGB", (4, 3)) @@ -658,7 +662,7 @@ def test_polygon_1px_high_translucent(): assert_image_equal_tofile(im, expected) -def test_polygon_translucent(): +def test_polygon_translucent() -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im, "RGBA") @@ -672,7 +676,7 @@ def test_polygon_translucent(): @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle(bbox): +def test_rectangle(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -684,7 +688,7 @@ def test_rectangle(bbox): assert_image_equal_tofile(im, "Tests/images/imagedraw_rectangle.png") -def test_big_rectangle(): +def test_big_rectangle() -> None: # Test drawing a rectangle bigger than the image # Arrange im = Image.new("RGB", (W, H)) @@ -699,7 +703,7 @@ def test_big_rectangle(): @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_width(bbox): +def test_rectangle_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -713,7 +717,7 @@ def test_rectangle_width(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_width_fill(bbox): +def test_rectangle_width_fill(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -727,7 +731,7 @@ def test_rectangle_width_fill(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_zero_width(bbox): +def test_rectangle_zero_width(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -740,7 +744,7 @@ def test_rectangle_zero_width(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_I16(bbox): +def test_rectangle_I16(bbox: Coords) -> None: # Arrange im = Image.new("I;16", (W, H)) draw = ImageDraw.Draw(im) @@ -749,11 +753,11 @@ def test_rectangle_I16(bbox): draw.rectangle(bbox, outline=0xFFFF) # Assert - assert_image_equal_tofile(im.convert("I"), "Tests/images/imagedraw_rectangle_I.png") + assert_image_equal_tofile(im, "Tests/images/imagedraw_rectangle_I.tiff") @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle_translucent_outline(bbox): +def test_rectangle_translucent_outline(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im, "RGBA") @@ -771,7 +775,13 @@ def test_rectangle_translucent_outline(bbox): "xy", [(10, 20, 190, 180), ([10, 20], [190, 180]), ((10, 20), (190, 180))], ) -def test_rounded_rectangle(xy): +def test_rounded_rectangle( + xy: ( + tuple[int, int, int, int] + | tuple[list[int]] + | tuple[tuple[int, int], tuple[int, int]] + ) +) -> None: # Arrange im = Image.new("RGB", (200, 200)) draw = ImageDraw.Draw(im) @@ -787,7 +797,9 @@ def test_rounded_rectangle(xy): @pytest.mark.parametrize("top_right", (True, False)) @pytest.mark.parametrize("bottom_right", (True, False)) @pytest.mark.parametrize("bottom_left", (True, False)) -def test_rounded_rectangle_corners(top_left, top_right, bottom_right, bottom_left): +def test_rounded_rectangle_corners( + top_left: bool, top_right: bool, bottom_right: bool, bottom_left: bool +) -> None: corners = (top_left, top_right, bottom_right, bottom_left) # Arrange @@ -821,7 +833,9 @@ def test_rounded_rectangle_corners(top_left, top_right, bottom_right, bottom_lef ((10, 20, 190, 181), 85, "height"), ], ) -def test_rounded_rectangle_non_integer_radius(xy, radius, type): +def test_rounded_rectangle_non_integer_radius( + xy: tuple[int, int, int, int], radius: float, type: str +) -> None: # Arrange im = Image.new("RGB", (200, 200)) draw = ImageDraw.Draw(im) @@ -837,7 +851,7 @@ def test_rounded_rectangle_non_integer_radius(xy, radius, type): @pytest.mark.parametrize("bbox", BBOX) -def test_rounded_rectangle_zero_radius(bbox): +def test_rounded_rectangle_zero_radius(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -859,7 +873,9 @@ def test_rounded_rectangle_zero_radius(bbox): ((20, 20, 80, 80), "both"), ], ) -def test_rounded_rectangle_translucent(xy, suffix): +def test_rounded_rectangle_translucent( + xy: tuple[int, int, int, int], suffix: str +) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im, "RGBA") @@ -876,7 +892,7 @@ def test_rounded_rectangle_translucent(xy, suffix): @pytest.mark.parametrize("bbox", BBOX) -def test_floodfill(bbox): +def test_floodfill(bbox: Coords) -> None: red = ImageColor.getrgb("red") for mode, value in [("L", 1), ("RGBA", (255, 0, 0, 0)), ("RGB", red)]: @@ -909,7 +925,7 @@ def test_floodfill(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_floodfill_border(bbox): +def test_floodfill_border(bbox: Coords) -> None: # floodfill() is experimental # Arrange @@ -931,7 +947,7 @@ def test_floodfill_border(bbox): @pytest.mark.parametrize("bbox", BBOX) -def test_floodfill_thresh(bbox): +def test_floodfill_thresh(bbox: Coords) -> None: # floodfill() is experimental # Arrange @@ -947,7 +963,7 @@ def test_floodfill_thresh(bbox): assert_image_equal_tofile(im, "Tests/images/imagedraw_floodfill2.png") -def test_floodfill_not_negative(): +def test_floodfill_not_negative() -> None: # floodfill() is experimental # Test that floodfill does not extend into negative coordinates @@ -965,8 +981,11 @@ def test_floodfill_not_negative(): def create_base_image_draw( - size, mode=DEFAULT_MODE, background1=WHITE, background2=GRAY -): + size: tuple[int, int], + mode: str = DEFAULT_MODE, + background1: tuple[int, int, int] = WHITE, + background2: tuple[int, int, int] = GRAY, +) -> tuple[Image.Image, ImageDraw.ImageDraw]: img = Image.new(mode, size, background1) for x in range(0, size[0]): for y in range(0, size[1]): @@ -975,7 +994,7 @@ def create_base_image_draw( return img, ImageDraw.Draw(img) -def test_square(): +def test_square() -> None: expected = os.path.join(IMAGES_PATH, "square.png") img, draw = create_base_image_draw((10, 10)) draw.polygon([(2, 2), (2, 7), (7, 7), (7, 2)], BLACK) @@ -988,7 +1007,7 @@ def test_square(): assert_image_equal_tofile(img, expected, "square as normal rectangle failed") -def test_triangle_right(): +def test_triangle_right() -> None: img, draw = create_base_image_draw((20, 20)) draw.polygon([(3, 5), (17, 5), (10, 12)], BLACK) assert_image_equal_tofile( @@ -1000,7 +1019,7 @@ def test_triangle_right(): "fill, suffix", ((BLACK, "width"), (None, "width_no_fill")), ) -def test_triangle_right_width(fill, suffix): +def test_triangle_right_width(fill: tuple[int, int, int] | None, suffix: str) -> None: img, draw = create_base_image_draw((100, 100)) draw.polygon([(15, 25), (85, 25), (50, 60)], fill, WHITE, width=5) assert_image_equal_tofile( @@ -1008,7 +1027,7 @@ def test_triangle_right_width(fill, suffix): ) -def test_line_horizontal(): +def test_line_horizontal() -> None: img, draw = create_base_image_draw((20, 20)) draw.line((5, 5, 14, 5), BLACK, 2) assert_image_equal_tofile( @@ -1046,7 +1065,7 @@ def test_line_horizontal(): ) -def test_line_h_s1_w2(): +def test_line_h_s1_w2() -> None: pytest.skip("failing") img, draw = create_base_image_draw((20, 20)) draw.line((5, 5, 14, 6), BLACK, 2) @@ -1057,7 +1076,7 @@ def test_line_h_s1_w2(): ) -def test_line_vertical(): +def test_line_vertical() -> None: img, draw = create_base_image_draw((20, 20)) draw.line((5, 5, 5, 14), BLACK, 2) assert_image_equal_tofile( @@ -1103,7 +1122,7 @@ def test_line_vertical(): ) -def test_line_oblique_45(): +def test_line_oblique_45() -> None: expected = os.path.join(IMAGES_PATH, "line_oblique_45_w3px_a.png") img, draw = create_base_image_draw((20, 20)) draw.line((5, 5, 14, 14), BLACK, 3) @@ -1125,7 +1144,7 @@ def test_line_oblique_45(): ) -def test_wide_line_dot(): +def test_wide_line_dot() -> None: # Test drawing a wide "line" from one point to another just draws a single point # Arrange im = Image.new("RGB", (W, H)) @@ -1138,7 +1157,7 @@ def test_wide_line_dot(): assert_image_similar_tofile(im, "Tests/images/imagedraw_wide_line_dot.png", 1) -def test_wide_line_larger_than_int(): +def test_wide_line_larger_than_int() -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -1232,7 +1251,7 @@ def test_wide_line_larger_than_int(): ], ], ) -def test_line_joint(xy): +def test_line_joint(xy: list[tuple[int, int]] | tuple[int, ...] | list[int]) -> None: im = Image.new("RGB", (500, 325)) draw = ImageDraw.Draw(im) @@ -1243,7 +1262,7 @@ def test_line_joint(xy): assert_image_similar_tofile(im, "Tests/images/imagedraw_line_joint_curve.png", 3) -def test_textsize_empty_string(): +def test_textsize_empty_string() -> None: # https://github.com/python-pillow/Pillow/issues/2783 # Arrange im = Image.new("RGB", (W, H)) @@ -1259,7 +1278,7 @@ def test_textsize_empty_string(): @skip_unless_feature("freetype2") -def test_textbbox_stroke(): +def test_textbbox_stroke() -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) @@ -1273,7 +1292,7 @@ def test_textbbox_stroke(): @skip_unless_feature("freetype2") -def test_stroke(): +def test_stroke() -> None: for suffix, stroke_fill in {"same": None, "different": "#0f0"}.items(): # Arrange im = Image.new("RGB", (120, 130)) @@ -1290,7 +1309,7 @@ def test_stroke(): @skip_unless_feature("freetype2") -def test_stroke_descender(): +def test_stroke_descender() -> None: # Arrange im = Image.new("RGB", (120, 130)) draw = ImageDraw.Draw(im) @@ -1304,7 +1323,7 @@ def test_stroke_descender(): @skip_unless_feature("freetype2") -def test_split_word(): +def test_split_word() -> None: # Arrange im = Image.new("RGB", (230, 55)) expected = im.copy() @@ -1325,7 +1344,7 @@ def test_split_word(): @skip_unless_feature("freetype2") -def test_stroke_multiline(): +def test_stroke_multiline() -> None: # Arrange im = Image.new("RGB", (100, 250)) draw = ImageDraw.Draw(im) @@ -1341,7 +1360,7 @@ def test_stroke_multiline(): @skip_unless_feature("freetype2") -def test_setting_default_font(): +def test_setting_default_font() -> None: # Arrange im = Image.new("RGB", (100, 250)) draw = ImageDraw.Draw(im) @@ -1358,7 +1377,7 @@ def test_setting_default_font(): assert isinstance(draw.getfont(), ImageFont.load_default().__class__) -def test_default_font_size(): +def test_default_font_size() -> None: freetype_support = features.check_module("freetype2") text = "Default font at a specific size." @@ -1385,7 +1404,7 @@ def test_default_font_size(): @pytest.mark.parametrize("bbox", BBOX) -def test_same_color_outline(bbox): +def test_same_color_outline(bbox: Coords) -> None: # Prepare shape x0, y0 = 5, 5 x1, y1 = 5, 50 @@ -1399,7 +1418,8 @@ def test_same_color_outline(bbox): # Begin for mode in ["RGB", "L"]: - for fill, outline in [["red", None], ["red", "red"], ["red", "#f00"]]: + fill = "red" + for outline in [None, "red", "#f00"]: for operation, args in { "chord": [bbox, 0, 180], "ellipse": [bbox], @@ -1414,6 +1434,7 @@ def test_same_color_outline(bbox): # Act draw_method = getattr(draw, operation) + assert isinstance(args, list) args += [fill, outline] draw_method(*args) @@ -1431,7 +1452,9 @@ def test_same_color_outline(bbox): (3, "triangle_width", {"width": 5, "outline": "yellow"}), ], ) -def test_draw_regular_polygon(n_sides, polygon_name, args): +def test_draw_regular_polygon( + n_sides: int, polygon_name: str, args: dict[str, int | str] +) -> None: im = Image.new("RGBA", size=(W, H), color=(255, 0, 0, 0)) filename = f"Tests/images/imagedraw_{polygon_name}.png" draw = ImageDraw.Draw(im) @@ -1468,7 +1491,9 @@ def test_draw_regular_polygon(n_sides, polygon_name, args): ), ], ) -def test_compute_regular_polygon_vertices(n_sides, expected_vertices): +def test_compute_regular_polygon_vertices( + n_sides: int, expected_vertices: list[tuple[float, float]] +) -> None: bounding_circle = (W // 2, H // 2, 25) vertices = ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, 0) assert vertices == expected_vertices @@ -1479,7 +1504,7 @@ def test_compute_regular_polygon_vertices(n_sides, expected_vertices): [ (None, (50, 50, 25), 0, TypeError, "n_sides should be an int"), (1, (50, 50, 25), 0, ValueError, "n_sides should be an int > 2"), - (3, 50, 0, TypeError, "bounding_circle should be a tuple"), + (3, 50, 0, TypeError, "bounding_circle should be a sequence"), ( 3, (50, 50, 100, 100), @@ -1520,13 +1545,13 @@ def test_compute_regular_polygon_vertices(n_sides, expected_vertices): ) def test_compute_regular_polygon_vertices_input_error_handling( n_sides, bounding_circle, rotation, expected_error, error_message -): +) -> None: with pytest.raises(expected_error) as e: ImageDraw._compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) assert str(e.value) == error_message -def test_continuous_horizontal_edges_polygon(): +def test_continuous_horizontal_edges_polygon() -> None: xy = [ (2, 6), (6, 6), @@ -1545,7 +1570,7 @@ def test_continuous_horizontal_edges_polygon(): ) -def test_discontiguous_corners_polygon(): +def test_discontiguous_corners_polygon() -> None: img, draw = create_base_image_draw((84, 68)) draw.polygon(((1, 21), (34, 4), (71, 1), (38, 18)), BLACK) draw.polygon(((71, 44), (38, 27), (1, 24)), BLACK) @@ -1557,7 +1582,7 @@ def test_discontiguous_corners_polygon(): assert_image_similar_tofile(img, expected, 1) -def test_polygon2(): +def test_polygon2() -> None: im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) draw.polygon([(18, 30), (19, 31), (18, 30), (85, 30), (60, 72)], "red") @@ -1566,7 +1591,7 @@ def test_polygon2(): @pytest.mark.parametrize("xy", ((1, 1, 0, 1), (1, 1, 1, 0))) -def test_incorrectly_ordered_coordinates(xy): +def test_incorrectly_ordered_coordinates(xy: tuple[int, int, int, int]) -> None: im = Image.new("RGB", (W, H)) draw = ImageDraw.Draw(im) with pytest.raises(ValueError): diff --git a/Tests/test_imagedraw2.py b/Tests/test_imagedraw2.py index d729af14d..3171eb9ae 100644 --- a/Tests/test_imagedraw2.py +++ b/Tests/test_imagedraw2.py @@ -1,9 +1,11 @@ from __future__ import annotations + import os.path import pytest from PIL import Image, ImageDraw, ImageDraw2, features +from PIL._typing import Coords from .helper import ( assert_image_equal, @@ -42,7 +44,7 @@ POINTS = ( FONT_PATH = "Tests/fonts/FreeMono.ttf" -def test_sanity(): +def test_sanity() -> None: im = hopper("RGB").copy() draw = ImageDraw2.Draw(im) @@ -55,7 +57,7 @@ def test_sanity(): @pytest.mark.parametrize("bbox", BBOX) -def test_ellipse(bbox): +def test_ellipse(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -69,7 +71,7 @@ def test_ellipse(bbox): assert_image_similar_tofile(im, "Tests/images/imagedraw_ellipse_RGB.png", 1) -def test_ellipse_edge(): +def test_ellipse_edge() -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -83,7 +85,7 @@ def test_ellipse_edge(): @pytest.mark.parametrize("points", POINTS) -def test_line(points): +def test_line(points: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -97,7 +99,7 @@ def test_line(points): @pytest.mark.parametrize("points", POINTS) -def test_line_pen_as_brush(points): +def test_line_pen_as_brush(points: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -113,7 +115,7 @@ def test_line_pen_as_brush(points): @pytest.mark.parametrize("points", POINTS) -def test_polygon(points): +def test_polygon(points: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -128,7 +130,7 @@ def test_polygon(points): @pytest.mark.parametrize("bbox", BBOX) -def test_rectangle(bbox): +def test_rectangle(bbox: Coords) -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -142,7 +144,7 @@ def test_rectangle(bbox): assert_image_equal_tofile(im, "Tests/images/imagedraw_rectangle.png") -def test_big_rectangle(): +def test_big_rectangle() -> None: # Test drawing a rectangle bigger than the image # Arrange im = Image.new("RGB", (W, H)) @@ -159,7 +161,7 @@ def test_big_rectangle(): @skip_unless_feature("freetype2") -def test_text(): +def test_text() -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -174,7 +176,7 @@ def test_text(): @skip_unless_feature("freetype2") -def test_textbbox(): +def test_textbbox() -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -189,7 +191,7 @@ def test_textbbox(): @skip_unless_feature("freetype2") -def test_textsize_empty_string(): +def test_textsize_empty_string() -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) @@ -205,7 +207,7 @@ def test_textsize_empty_string(): @skip_unless_feature("freetype2") -def test_flush(): +def test_flush() -> None: # Arrange im = Image.new("RGB", (W, H)) draw = ImageDraw2.Draw(im) diff --git a/Tests/test_imageenhance.py b/Tests/test_imageenhance.py index f4e4d59be..6ebc61e1b 100644 --- a/Tests/test_imageenhance.py +++ b/Tests/test_imageenhance.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, ImageEnhance @@ -6,7 +7,7 @@ from PIL import Image, ImageEnhance from .helper import assert_image_equal, hopper -def test_sanity(): +def test_sanity() -> None: # FIXME: assert_image # Implicit asserts no exception: ImageEnhance.Color(hopper()).enhance(0.5) @@ -15,13 +16,13 @@ def test_sanity(): ImageEnhance.Sharpness(hopper()).enhance(0.5) -def test_crash(): +def test_crash() -> None: # crashes on small images im = Image.new("RGB", (1, 1)) ImageEnhance.Sharpness(im).enhance(0.5) -def _half_transparent_image(): +def _half_transparent_image() -> Image.Image: # returns an image, half transparent, half solid im = hopper("RGB") @@ -33,7 +34,9 @@ def _half_transparent_image(): return im -def _check_alpha(im, original, op, amount): +def _check_alpha( + im: Image.Image, original: Image.Image, op: str, amount: float +) -> None: assert im.getbands() == original.getbands() assert_image_equal( im.getchannel("A"), @@ -43,7 +46,7 @@ def _check_alpha(im, original, op, amount): @pytest.mark.parametrize("op", ("Color", "Brightness", "Contrast", "Sharpness")) -def test_alpha(op): +def test_alpha(op: str) -> None: # Issue https://github.com/python-pillow/Pillow/issues/899 # Is alpha preserved through image enhancement? diff --git a/Tests/test_imagefile.py b/Tests/test_imagefile.py index 4804a554f..ddcae80d6 100644 --- a/Tests/test_imagefile.py +++ b/Tests/test_imagefile.py @@ -1,5 +1,7 @@ from __future__ import annotations + from io import BytesIO +from typing import Any import pytest @@ -29,8 +31,8 @@ SAFEBLOCK = ImageFile.SAFEBLOCK class TestImageFile: - def test_parser(self): - def roundtrip(format): + def test_parser(self) -> None: + def roundtrip(format: str) -> tuple[Image.Image, Image.Image]: im = hopper("L").resize((1000, 1000), Image.Resampling.NEAREST) if format in ("MSP", "XBM"): im = im.convert("1") @@ -83,7 +85,7 @@ class TestImageFile: with pytest.raises(OSError): roundtrip("PDF") - def test_ico(self): + def test_ico(self) -> None: with open("Tests/images/python.ico", "rb") as f: data = f.read() with ImageFile.Parser() as p: @@ -92,7 +94,7 @@ class TestImageFile: @skip_unless_feature("webp") @skip_unless_feature("webp_anim") - def test_incremental_webp(self): + def test_incremental_webp(self) -> None: with ImageFile.Parser() as p: with open("Tests/images/hopper.webp", "rb") as f: p.feed(f.read(1024)) @@ -104,7 +106,7 @@ class TestImageFile: assert (128, 128) == p.image.size @skip_unless_feature("zlib") - def test_safeblock(self): + def test_safeblock(self) -> None: im1 = hopper() try: @@ -115,17 +117,17 @@ class TestImageFile: assert_image_equal(im1, im2) - def test_raise_oserror(self): + def test_raise_oserror(self) -> None: with pytest.warns(DeprecationWarning): with pytest.raises(OSError): ImageFile.raise_oserror(1) - def test_raise_typeerror(self): + def test_raise_typeerror(self) -> None: with pytest.raises(TypeError): parser = ImageFile.Parser() parser.feed(1) - def test_negative_stride(self): + def test_negative_stride(self) -> None: with open("Tests/images/raw_negative_stride.bin", "rb") as f: input = f.read() p = ImageFile.Parser() @@ -133,11 +135,11 @@ class TestImageFile: with pytest.raises(OSError): p.close() - def test_no_format(self): + def test_no_format(self) -> None: buf = BytesIO(b"\x00" * 255) class DummyImageFile(ImageFile.ImageFile): - def _open(self): + def _open(self) -> None: self._mode = "RGB" self._size = (1, 1) @@ -145,12 +147,12 @@ class TestImageFile: assert im.format is None assert im.get_format_mimetype() is None - def test_oserror(self): + def test_oserror(self) -> None: im = Image.new("RGB", (1, 1)) with pytest.raises(OSError): im.save(BytesIO(), "JPEG2000", num_resolutions=2) - def test_truncated(self): + def test_truncated(self) -> None: b = BytesIO( b"BM000000000000" # head_data + _binary.o32le( @@ -165,7 +167,7 @@ class TestImageFile: assert str(e.value) == "Truncated File Read" @skip_unless_feature("zlib") - def test_truncated_with_errors(self): + def test_truncated_with_errors(self) -> None: with Image.open("Tests/images/truncated_image.png") as im: with pytest.raises(OSError): im.load() @@ -175,7 +177,7 @@ class TestImageFile: im.load() @skip_unless_feature("zlib") - def test_truncated_without_errors(self): + def test_truncated_without_errors(self) -> None: with Image.open("Tests/images/truncated_image.png") as im: ImageFile.LOAD_TRUNCATED_IMAGES = True try: @@ -184,13 +186,13 @@ class TestImageFile: ImageFile.LOAD_TRUNCATED_IMAGES = False @skip_unless_feature("zlib") - def test_broken_datastream_with_errors(self): + def test_broken_datastream_with_errors(self) -> None: with Image.open("Tests/images/broken_data_stream.png") as im: with pytest.raises(OSError): im.load() @skip_unless_feature("zlib") - def test_broken_datastream_without_errors(self): + def test_broken_datastream_without_errors(self) -> None: with Image.open("Tests/images/broken_data_stream.png") as im: ImageFile.LOAD_TRUNCATED_IMAGES = True try: @@ -200,16 +202,26 @@ class TestImageFile: class MockPyDecoder(ImageFile.PyDecoder): + def __init__(self, mode: str, *args: Any) -> None: + MockPyDecoder.last = self + + super().__init__(mode, *args) + def decode(self, buffer): # eof return -1, 0 class MockPyEncoder(ImageFile.PyEncoder): + def __init__(self, mode: str, *args: Any) -> None: + MockPyEncoder.last = self + + super().__init__(mode, *args) + def encode(self, buffer): return 1, 1, b"" - def cleanup(self): + def cleanup(self) -> None: self.cleanup_called = True @@ -217,7 +229,7 @@ xoff, yoff, xsize, ysize = 10, 20, 100, 100 class MockImageFile(ImageFile.ImageFile): - def _open(self): + def _open(self) -> None: self.rawmode = "RGBA" self._mode = "RGBA" self._size = (200, 200) @@ -226,39 +238,28 @@ class MockImageFile(ImageFile.ImageFile): class CodecsTest: @classmethod - def setup_class(cls): - cls.decoder = MockPyDecoder(None) - cls.encoder = MockPyEncoder(None) - - def decoder_closure(mode, *args): - cls.decoder.__init__(mode, *args) - return cls.decoder - - def encoder_closure(mode, *args): - cls.encoder.__init__(mode, *args) - return cls.encoder - - Image.register_decoder("MOCK", decoder_closure) - Image.register_encoder("MOCK", encoder_closure) + def setup_class(cls) -> None: + Image.register_decoder("MOCK", MockPyDecoder) + Image.register_encoder("MOCK", MockPyEncoder) class TestPyDecoder(CodecsTest): - def test_setimage(self): + def test_setimage(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) im.load() - assert self.decoder.state.xoff == xoff - assert self.decoder.state.yoff == yoff - assert self.decoder.state.xsize == xsize - assert self.decoder.state.ysize == ysize + assert MockPyDecoder.last.state.xoff == xoff + assert MockPyDecoder.last.state.yoff == yoff + assert MockPyDecoder.last.state.xsize == xsize + assert MockPyDecoder.last.state.ysize == ysize with pytest.raises(ValueError): - self.decoder.set_as_raw(b"\x00") + MockPyDecoder.last.set_as_raw(b"\x00") - def test_extents_none(self): + def test_extents_none(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) @@ -266,12 +267,12 @@ class TestPyDecoder(CodecsTest): im.load() - assert self.decoder.state.xoff == 0 - assert self.decoder.state.yoff == 0 - assert self.decoder.state.xsize == 200 - assert self.decoder.state.ysize == 200 + assert MockPyDecoder.last.state.xoff == 0 + assert MockPyDecoder.last.state.yoff == 0 + assert MockPyDecoder.last.state.xsize == 200 + assert MockPyDecoder.last.state.ysize == 200 - def test_negsize(self): + def test_negsize(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) @@ -284,7 +285,7 @@ class TestPyDecoder(CodecsTest): with pytest.raises(ValueError): im.load() - def test_oversize(self): + def test_oversize(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) @@ -297,14 +298,14 @@ class TestPyDecoder(CodecsTest): with pytest.raises(ValueError): im.load() - def test_decode(self): + def test_decode(self) -> None: decoder = ImageFile.PyDecoder(None) with pytest.raises(NotImplementedError): decoder.decode(None) class TestPyEncoder(CodecsTest): - def test_setimage(self): + def test_setimage(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) @@ -314,12 +315,12 @@ class TestPyEncoder(CodecsTest): im, fp, [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize), 0, "RGB")] ) - assert self.encoder.state.xoff == xoff - assert self.encoder.state.yoff == yoff - assert self.encoder.state.xsize == xsize - assert self.encoder.state.ysize == ysize + assert MockPyEncoder.last.state.xoff == xoff + assert MockPyEncoder.last.state.yoff == yoff + assert MockPyEncoder.last.state.xsize == xsize + assert MockPyEncoder.last.state.ysize == ysize - def test_extents_none(self): + def test_extents_none(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) @@ -328,30 +329,30 @@ class TestPyEncoder(CodecsTest): fp = BytesIO() ImageFile._save(im, fp, [("MOCK", None, 0, "RGB")]) - assert self.encoder.state.xoff == 0 - assert self.encoder.state.yoff == 0 - assert self.encoder.state.xsize == 200 - assert self.encoder.state.ysize == 200 + assert MockPyEncoder.last.state.xoff == 0 + assert MockPyEncoder.last.state.yoff == 0 + assert MockPyEncoder.last.state.xsize == 200 + assert MockPyEncoder.last.state.ysize == 200 - def test_negsize(self): + def test_negsize(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) fp = BytesIO() - self.encoder.cleanup_called = False + MockPyEncoder.last = None with pytest.raises(ValueError): ImageFile._save( im, fp, [("MOCK", (xoff, yoff, -10, yoff + ysize), 0, "RGB")] ) - assert self.encoder.cleanup_called + assert MockPyEncoder.last.cleanup_called with pytest.raises(ValueError): ImageFile._save( im, fp, [("MOCK", (xoff, yoff, xoff + xsize, -10), 0, "RGB")] ) - def test_oversize(self): + def test_oversize(self) -> None: buf = BytesIO(b"\x00" * 255) im = MockImageFile(buf) @@ -371,7 +372,7 @@ class TestPyEncoder(CodecsTest): [("MOCK", (xoff, yoff, xoff + xsize, yoff + ysize + 100), 0, "RGB")], ) - def test_encode(self): + def test_encode(self) -> None: encoder = ImageFile.PyEncoder(None) with pytest.raises(NotImplementedError): encoder.encode(None) @@ -387,6 +388,6 @@ class TestPyEncoder(CodecsTest): with pytest.raises(NotImplementedError): encoder.encode_to_file(None, None) - def test_zero_height(self): + def test_zero_height(self) -> None: with pytest.raises(UnidentifiedImageError): Image.open("Tests/images/zero_height.j2k") diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index 6e04cddc7..05b5d4716 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -1,4 +1,5 @@ from __future__ import annotations + import copy import os import re @@ -6,11 +7,13 @@ import shutil import sys from io import BytesIO from pathlib import Path +from typing import Any, BinaryIO import pytest from packaging.version import parse as parse_version from PIL import Image, ImageDraw, ImageFont, features +from PIL._typing import StrOrBytesPath from .helper import ( assert_image_equal, @@ -30,7 +33,7 @@ TEST_TEXT = "hey you\nyou are awesome\nthis looks awkward" pytestmark = skip_unless_feature("freetype2") -def test_sanity(): +def test_sanity() -> None: assert re.search(r"\d+\.\d+\.\d+$", features.version_module("freetype2")) @@ -41,16 +44,16 @@ def test_sanity(): pytest.param(ImageFont.Layout.RAQM, marks=skip_unless_feature("raqm")), ], ) -def layout_engine(request): +def layout_engine(request: pytest.FixtureRequest) -> ImageFont.Layout: return request.param @pytest.fixture(scope="module") -def font(layout_engine): +def font(layout_engine: ImageFont.Layout) -> ImageFont.FreeTypeFont: return ImageFont.truetype(FONT_PATH, FONT_SIZE, layout_engine=layout_engine) -def test_font_properties(font): +def test_font_properties(font: ImageFont.FreeTypeFont) -> None: assert font.path == FONT_PATH assert font.size == FONT_SIZE @@ -66,7 +69,9 @@ def test_font_properties(font): assert font_copy.path == second_font_path -def _render(font, layout_engine): +def _render( + font: StrOrBytesPath | BinaryIO, layout_engine: ImageFont.Layout +) -> Image.Image: txt = "Hello World!" ttf = ImageFont.truetype(font, FONT_SIZE, layout_engine=layout_engine) ttf.getbbox(txt) @@ -79,12 +84,12 @@ def _render(font, layout_engine): @pytest.mark.parametrize("font", (FONT_PATH, Path(FONT_PATH))) -def test_font_with_name(layout_engine, font): +def test_font_with_name(layout_engine: ImageFont.Layout, font: str | Path) -> None: _render(font, layout_engine) -def test_font_with_filelike(layout_engine): - def _font_as_bytes(): +def test_font_with_filelike(layout_engine: ImageFont.Layout) -> None: + def _font_as_bytes() -> BytesIO: with open(FONT_PATH, "rb") as f: font_bytes = BytesIO(f.read()) return font_bytes @@ -101,12 +106,12 @@ def test_font_with_filelike(layout_engine): # _render(shared_bytes) -def test_font_with_open_file(layout_engine): +def test_font_with_open_file(layout_engine: ImageFont.Layout) -> None: with open(FONT_PATH, "rb") as f: _render(f, layout_engine) -def test_render_equal(layout_engine): +def test_render_equal(layout_engine: ImageFont.Layout) -> None: img_path = _render(FONT_PATH, layout_engine) with open(FONT_PATH, "rb") as f: font_filelike = BytesIO(f.read()) @@ -115,7 +120,7 @@ def test_render_equal(layout_engine): assert_image_equal(img_path, img_filelike) -def test_non_ascii_path(tmp_path, layout_engine): +def test_non_ascii_path(tmp_path: Path, layout_engine: ImageFont.Layout) -> None: tempfile = str(tmp_path / ("temp_" + chr(128) + ".ttf")) try: shutil.copy(FONT_PATH, tempfile) @@ -125,7 +130,7 @@ def test_non_ascii_path(tmp_path, layout_engine): ImageFont.truetype(tempfile, FONT_SIZE, layout_engine=layout_engine) -def test_transparent_background(font): +def test_transparent_background(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="RGBA", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -139,7 +144,7 @@ def test_transparent_background(font): assert_image_similar_tofile(im.convert("L"), target, 0.01) -def test_I16(font): +def test_I16(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="I;16", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -152,7 +157,7 @@ def test_I16(font): assert_image_similar_tofile(im.convert("L"), target, 0.01) -def test_textbbox_equal(font): +def test_textbbox_equal(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -180,8 +185,14 @@ def test_textbbox_equal(font): ), ) def test_getlength( - text, mode, fontname, size, layout_engine, length_basic, length_raqm -): + text: str, + mode: str, + fontname: str, + size: int, + layout_engine: ImageFont.Layout, + length_basic: int, + length_raqm: float, +) -> None: f = ImageFont.truetype("Tests/fonts/" + fontname, size, layout_engine=layout_engine) im = Image.new(mode, (1, 1), 0) @@ -196,7 +207,7 @@ def test_getlength( assert length == length_raqm -def test_float_size(): +def test_float_size() -> None: lengths = [] for size in (48, 48.5, 49): f = ImageFont.truetype( @@ -206,7 +217,7 @@ def test_float_size(): assert lengths[0] != lengths[1] != lengths[2] -def test_render_multiline(font): +def test_render_multiline(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) line_spacing = font.getbbox("A")[3] + 4 @@ -222,7 +233,7 @@ def test_render_multiline(font): assert_image_similar_tofile(im, "Tests/images/multiline_text.png", 6.2) -def test_render_multiline_text(font): +def test_render_multiline_text(font: ImageFont.FreeTypeFont) -> None: # Test that text() correctly connects to multiline_text() # and that align defaults to left im = Image.new(mode="RGB", size=(300, 100)) @@ -242,7 +253,9 @@ def test_render_multiline_text(font): @pytest.mark.parametrize( "align, ext", (("left", ""), ("center", "_center"), ("right", "_right")) ) -def test_render_multiline_text_align(font, align, ext): +def test_render_multiline_text_align( + font: ImageFont.FreeTypeFont, align: str, ext: str +) -> None: im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) draw.multiline_text((0, 0), TEST_TEXT, font=font, align=align) @@ -250,7 +263,7 @@ def test_render_multiline_text_align(font, align, ext): assert_image_similar_tofile(im, f"Tests/images/multiline_text{ext}.png", 0.01) -def test_unknown_align(font): +def test_unknown_align(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -259,14 +272,14 @@ def test_unknown_align(font): draw.multiline_text((0, 0), TEST_TEXT, font=font, align="unknown") -def test_draw_align(font): +def test_draw_align(font: ImageFont.FreeTypeFont) -> None: im = Image.new("RGB", (300, 100), "white") draw = ImageDraw.Draw(im) line = "some text" draw.text((100, 40), line, (0, 0, 0), font=font, align="left") -def test_multiline_bbox(font): +def test_multiline_bbox(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -284,7 +297,7 @@ def test_multiline_bbox(font): draw.textbbox((0, 0), TEST_TEXT, font=font, spacing=4) -def test_multiline_width(font): +def test_multiline_width(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) @@ -294,7 +307,7 @@ def test_multiline_width(font): ) -def test_multiline_spacing(font): +def test_multiline_spacing(font: ImageFont.FreeTypeFont) -> None: im = Image.new(mode="RGB", size=(300, 100)) draw = ImageDraw.Draw(im) draw.multiline_text((0, 0), TEST_TEXT, font=font, spacing=10) @@ -305,7 +318,9 @@ def test_multiline_spacing(font): @pytest.mark.parametrize( "orientation", (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270) ) -def test_rotated_transposed_font(font, orientation): +def test_rotated_transposed_font( + font: ImageFont.FreeTypeFont, orientation: Image.Transpose +) -> None: img_gray = Image.new("L", (100, 100)) draw = ImageDraw.Draw(img_gray) word = "testing" @@ -346,7 +361,9 @@ def test_rotated_transposed_font(font, orientation): Image.Transpose.FLIP_TOP_BOTTOM, ), ) -def test_unrotated_transposed_font(font, orientation): +def test_unrotated_transposed_font( + font: ImageFont.FreeTypeFont, orientation: Image.Transpose +) -> None: img_gray = Image.new("L", (100, 100)) draw = ImageDraw.Draw(img_gray) word = "testing" @@ -381,7 +398,9 @@ def test_unrotated_transposed_font(font, orientation): @pytest.mark.parametrize( "orientation", (Image.Transpose.ROTATE_90, Image.Transpose.ROTATE_270) ) -def test_rotated_transposed_font_get_mask(font, orientation): +def test_rotated_transposed_font_get_mask( + font: ImageFont.FreeTypeFont, orientation: Image.Transpose +) -> None: # Arrange text = "mask this" transposed_font = ImageFont.TransposedFont(font, orientation=orientation) @@ -402,7 +421,9 @@ def test_rotated_transposed_font_get_mask(font, orientation): Image.Transpose.FLIP_TOP_BOTTOM, ), ) -def test_unrotated_transposed_font_get_mask(font, orientation): +def test_unrotated_transposed_font_get_mask( + font: ImageFont.FreeTypeFont, orientation: Image.Transpose +) -> None: # Arrange text = "mask this" transposed_font = ImageFont.TransposedFont(font, orientation=orientation) @@ -414,11 +435,11 @@ def test_unrotated_transposed_font_get_mask(font, orientation): assert mask.size == (108, 13) -def test_free_type_font_get_name(font): +def test_free_type_font_get_name(font: ImageFont.FreeTypeFont) -> None: assert ("FreeMono", "Regular") == font.getname() -def test_free_type_font_get_metrics(font): +def test_free_type_font_get_metrics(font: ImageFont.FreeTypeFont) -> None: ascent, descent = font.getmetrics() assert isinstance(ascent, int) @@ -426,7 +447,7 @@ def test_free_type_font_get_metrics(font): assert (ascent, descent) == (16, 4) -def test_free_type_font_get_mask(font): +def test_free_type_font_get_mask(font: ImageFont.FreeTypeFont) -> None: # Arrange text = "mask this" @@ -437,7 +458,7 @@ def test_free_type_font_get_mask(font): assert mask.size == (108, 13) -def test_load_path_not_found(): +def test_load_path_not_found() -> None: # Arrange filename = "somefilenamethatdoesntexist.ttf" @@ -448,13 +469,13 @@ def test_load_path_not_found(): ImageFont.truetype(filename) -def test_load_non_font_bytes(): +def test_load_non_font_bytes() -> None: with open("Tests/images/hopper.jpg", "rb") as f: with pytest.raises(OSError): ImageFont.truetype(f) -def test_default_font(): +def test_default_font() -> None: # Arrange txt = "This is a default font using FreeType support." im = Image.new(mode="RGB", size=(300, 100)) @@ -472,16 +493,16 @@ def test_default_font(): @pytest.mark.parametrize("mode", (None, "1", "RGBA")) -def test_getbbox(font, mode): +def test_getbbox(font: ImageFont.FreeTypeFont, mode: str | None) -> None: assert (0, 4, 12, 16) == font.getbbox("A", mode) -def test_getbbox_empty(font): +def test_getbbox_empty(font: ImageFont.FreeTypeFont) -> None: # issue #2614, should not crash. assert (0, 0, 0, 0) == font.getbbox("") -def test_render_empty(font): +def test_render_empty(font: ImageFont.FreeTypeFont) -> None: # issue 2666 im = Image.new(mode="RGB", size=(300, 100)) target = im.copy() @@ -491,7 +512,7 @@ def test_render_empty(font): assert_image_equal(im, target) -def test_unicode_extended(layout_engine): +def test_unicode_extended(layout_engine: ImageFont.Layout) -> None: # issue #3777 text = "A\u278A\U0001F12B" target = "Tests/images/unicode_extended.png" @@ -514,21 +535,23 @@ def test_unicode_extended(layout_engine): (("linux", "/usr/local/share/fonts"), ("darwin", "/System/Library/Fonts")), ) @pytest.mark.skipif(is_win32(), reason="requires Unix or macOS") -def test_find_font(monkeypatch, platform, font_directory): - def _test_fake_loading_font(path_to_fake, fontname): +def test_find_font( + monkeypatch: pytest.MonkeyPatch, platform: str, font_directory: str +) -> None: + def _test_fake_loading_font(path_to_fake: str, fontname: str) -> None: # Make a copy of FreeTypeFont so we can patch the original free_type_font = copy.deepcopy(ImageFont.FreeTypeFont) with monkeypatch.context() as m: m.setattr(ImageFont, "_FreeTypeFont", free_type_font, raising=False) - def loadable_font(filepath, size, index, encoding, *args, **kwargs): + def loadable_font( + filepath: str, size: int, index: int, encoding: str, *args: Any + ): if filepath == path_to_fake: return ImageFont._FreeTypeFont( - FONT_PATH, size, index, encoding, *args, **kwargs + FONT_PATH, size, index, encoding, *args ) - return ImageFont._FreeTypeFont( - filepath, size, index, encoding, *args, **kwargs - ) + return ImageFont._FreeTypeFont(filepath, size, index, encoding, *args) m.setattr(ImageFont, "FreeTypeFont", loadable_font) font = ImageFont.truetype(fontname) @@ -542,7 +565,7 @@ def test_find_font(monkeypatch, platform, font_directory): if platform == "linux": monkeypatch.setenv("XDG_DATA_DIRS", "/usr/share/:/usr/local/share/") - def fake_walker(path): + def fake_walker(path: str) -> list[tuple[str, list[str], list[str]]]: if path == font_directory: return [ ( @@ -566,7 +589,7 @@ def test_find_font(monkeypatch, platform, font_directory): _test_fake_loading_font(font_directory + "/Duplicate.ttf", "Duplicate") -def test_imagefont_getters(font): +def test_imagefont_getters(font: ImageFont.FreeTypeFont) -> None: assert font.getmetrics() == (16, 4) assert font.font.ascent == 16 assert font.font.descent == 4 @@ -587,7 +610,7 @@ def test_imagefont_getters(font): @pytest.mark.parametrize("stroke_width", (0, 2)) -def test_getsize_stroke(font, stroke_width): +def test_getsize_stroke(font: ImageFont.FreeTypeFont, stroke_width: int) -> None: assert font.getbbox("A", stroke_width=stroke_width) == ( 0 - stroke_width, 4 - stroke_width, @@ -596,7 +619,7 @@ def test_getsize_stroke(font, stroke_width): ) -def test_complex_font_settings(): +def test_complex_font_settings() -> None: t = ImageFont.truetype(FONT_PATH, FONT_SIZE, layout_engine=ImageFont.Layout.BASIC) with pytest.raises(KeyError): t.getmask("абвг", direction="rtl") @@ -606,7 +629,7 @@ def test_complex_font_settings(): t.getmask("абвг", language="sr") -def test_variation_get(font): +def test_variation_get(font: ImageFont.FreeTypeFont) -> None: freetype = parse_version(features.version_module("freetype2")) if freetype < parse_version("2.9.1"): with pytest.raises(NotImplementedError): @@ -661,7 +684,7 @@ def test_variation_get(font): ] -def _check_text(font, path, epsilon): +def _check_text(font: ImageFont.FreeTypeFont, path: str, epsilon: float) -> None: im = Image.new("RGB", (100, 75), "white") d = ImageDraw.Draw(im) d.text((10, 10), "Text", font=font, fill="black") @@ -676,7 +699,7 @@ def _check_text(font, path, epsilon): raise -def test_variation_set_by_name(font): +def test_variation_set_by_name(font: ImageFont.FreeTypeFont) -> None: freetype = parse_version(features.version_module("freetype2")) if freetype < parse_version("2.9.1"): with pytest.raises(NotImplementedError): @@ -701,7 +724,7 @@ def test_variation_set_by_name(font): _check_text(font, "Tests/images/variation_tiny_name.png", 40) -def test_variation_set_by_axes(font): +def test_variation_set_by_axes(font: ImageFont.FreeTypeFont) -> None: freetype = parse_version(features.version_module("freetype2")) if freetype < parse_version("2.9.1"): with pytest.raises(NotImplementedError): @@ -736,7 +759,9 @@ def test_variation_set_by_axes(font): ), ids=("ls", "ms", "rs", "ma", "mt", "mm", "mb", "md"), ) -def test_anchor(layout_engine, anchor, left, top): +def test_anchor( + layout_engine: ImageFont.Layout, anchor: str, left: int, top: int +) -> None: name, text = "quick", "Quick" path = f"Tests/images/test_anchor_{name}_{anchor}.png" @@ -781,7 +806,9 @@ def test_anchor(layout_engine, anchor, left, top): ("md", "center"), ), ) -def test_anchor_multiline(layout_engine, anchor, align): +def test_anchor_multiline( + layout_engine: ImageFont.Layout, anchor: str, align: str +) -> None: target = f"Tests/images/test_anchor_multiline_{anchor}_{align}.png" text = "a\nlong\ntext sample" @@ -799,7 +826,7 @@ def test_anchor_multiline(layout_engine, anchor, align): assert_image_similar_tofile(im, target, 4) -def test_anchor_invalid(font): +def test_anchor_invalid(font: ImageFont.FreeTypeFont) -> None: im = Image.new("RGB", (100, 100), "white") d = ImageDraw.Draw(im) d.font = font @@ -825,7 +852,7 @@ def test_anchor_invalid(font): @pytest.mark.parametrize("bpp", (1, 2, 4, 8)) -def test_bitmap_font(layout_engine, bpp): +def test_bitmap_font(layout_engine: ImageFont.Layout, bpp: int) -> None: text = "Bitmap Font" layout_name = ["basic", "raqm"][layout_engine] target = f"Tests/images/bitmap_font_{bpp}_{layout_name}.png" @@ -842,7 +869,7 @@ def test_bitmap_font(layout_engine, bpp): assert_image_equal_tofile(im, target) -def test_bitmap_font_stroke(layout_engine): +def test_bitmap_font_stroke(layout_engine: ImageFont.Layout) -> None: text = "Bitmap Font" layout_name = ["basic", "raqm"][layout_engine] target = f"Tests/images/bitmap_font_stroke_{layout_name}.png" @@ -860,7 +887,7 @@ def test_bitmap_font_stroke(layout_engine): @pytest.mark.parametrize("embedded_color", (False, True)) -def test_bitmap_blend(layout_engine, embedded_color): +def test_bitmap_blend(layout_engine: ImageFont.Layout, embedded_color: bool) -> None: font = ImageFont.truetype( "Tests/fonts/EBDTTestFont.ttf", size=64, layout_engine=layout_engine ) @@ -872,7 +899,7 @@ def test_bitmap_blend(layout_engine, embedded_color): assert_image_equal_tofile(im, "Tests/images/bitmap_font_blend.png") -def test_standard_embedded_color(layout_engine): +def test_standard_embedded_color(layout_engine: ImageFont.Layout) -> None: txt = "Hello World!" ttf = ImageFont.truetype(FONT_PATH, 40, layout_engine=layout_engine) ttf.getbbox(txt) @@ -885,7 +912,7 @@ def test_standard_embedded_color(layout_engine): @pytest.mark.parametrize("fontmode", ("1", "L", "RGBA")) -def test_float_coord(layout_engine, fontmode): +def test_float_coord(layout_engine: ImageFont.Layout, fontmode: str) -> None: txt = "Hello World!" ttf = ImageFont.truetype(FONT_PATH, 40, layout_engine=layout_engine) @@ -907,7 +934,7 @@ def test_float_coord(layout_engine, fontmode): raise -def test_cbdt(layout_engine): +def test_cbdt(layout_engine: ImageFont.Layout) -> None: try: font = ImageFont.truetype( "Tests/fonts/CBDTTestFont.ttf", size=64, layout_engine=layout_engine @@ -924,7 +951,7 @@ def test_cbdt(layout_engine): pytest.skip("freetype compiled without libpng or CBDT support") -def test_cbdt_mask(layout_engine): +def test_cbdt_mask(layout_engine: ImageFont.Layout) -> None: try: font = ImageFont.truetype( "Tests/fonts/CBDTTestFont.ttf", size=64, layout_engine=layout_engine @@ -941,7 +968,7 @@ def test_cbdt_mask(layout_engine): pytest.skip("freetype compiled without libpng or CBDT support") -def test_sbix(layout_engine): +def test_sbix(layout_engine: ImageFont.Layout) -> None: try: font = ImageFont.truetype( "Tests/fonts/chromacheck-sbix.woff", size=300, layout_engine=layout_engine @@ -958,7 +985,7 @@ def test_sbix(layout_engine): pytest.skip("freetype compiled without libpng or SBIX support") -def test_sbix_mask(layout_engine): +def test_sbix_mask(layout_engine: ImageFont.Layout) -> None: try: font = ImageFont.truetype( "Tests/fonts/chromacheck-sbix.woff", size=300, layout_engine=layout_engine @@ -976,7 +1003,7 @@ def test_sbix_mask(layout_engine): @skip_unless_feature_version("freetype2", "2.10.0") -def test_colr(layout_engine): +def test_colr(layout_engine: ImageFont.Layout) -> None: font = ImageFont.truetype( "Tests/fonts/BungeeColor-Regular_colr_Windows.ttf", size=64, @@ -992,7 +1019,7 @@ def test_colr(layout_engine): @skip_unless_feature_version("freetype2", "2.10.0") -def test_colr_mask(layout_engine): +def test_colr_mask(layout_engine: ImageFont.Layout) -> None: font = ImageFont.truetype( "Tests/fonts/BungeeColor-Regular_colr_Windows.ttf", size=64, @@ -1007,7 +1034,7 @@ def test_colr_mask(layout_engine): assert_image_similar_tofile(im, "Tests/images/colr_bungee_mask.png", 22) -def test_woff2(layout_engine): +def test_woff2(layout_engine: ImageFont.Layout) -> None: try: font = ImageFont.truetype( "Tests/fonts/OpenSans.woff2", @@ -1026,7 +1053,7 @@ def test_woff2(layout_engine): assert_image_similar_tofile(im, "Tests/images/test_woff2.png", 5) -def test_render_mono_size(): +def test_render_mono_size() -> None: # issue 4177 im = Image.new("P", (100, 30), "white") @@ -1041,7 +1068,7 @@ def test_render_mono_size(): assert_image_equal_tofile(im, "Tests/images/text_mono.gif") -def test_too_many_characters(font): +def test_too_many_characters(font: ImageFont.FreeTypeFont) -> None: with pytest.raises(ValueError): font.getlength("A" * 1_000_001) with pytest.raises(ValueError): @@ -1053,11 +1080,13 @@ def test_too_many_characters(font): with pytest.raises(ValueError): transposed_font.getlength("A" * 1_000_001) - default_font = ImageFont.load_default() + imagefont = ImageFont.ImageFont() with pytest.raises(ValueError): - default_font.getlength("A" * 1_000_001) + imagefont.getlength("A" * 1_000_001) with pytest.raises(ValueError): - default_font.getbbox("A" * 1_000_001) + imagefont.getbbox("A" * 1_000_001) + with pytest.raises(ValueError): + imagefont.getmask("A" * 1_000_001) @pytest.mark.parametrize( @@ -1067,14 +1096,14 @@ def test_too_many_characters(font): "Tests/fonts/oom-4da0210eb7081b0bf15bf16cc4c52ce02c1e1bbc.ttf", ], ) -def test_oom(test_file): +def test_oom(test_file: str) -> None: with open(test_file, "rb") as f: font = ImageFont.truetype(BytesIO(f.read())) with pytest.raises(Image.DecompressionBombError): font.getmask("Test Text") -def test_raqm_missing_warning(monkeypatch): +def test_raqm_missing_warning(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(ImageFont.core, "HAVE_RAQM", False) with pytest.warns(UserWarning) as record: font = ImageFont.truetype( @@ -1088,6 +1117,8 @@ def test_raqm_missing_warning(monkeypatch): @pytest.mark.parametrize("size", [-1, 0]) -def test_invalid_truetype_sizes_raise_valueerror(layout_engine, size): +def test_invalid_truetype_sizes_raise_valueerror( + layout_engine: ImageFont.Layout, size: int +) -> None: with pytest.raises(ValueError): ImageFont.truetype(FONT_PATH, size, layout_engine=layout_engine) diff --git a/Tests/test_imagefontctl.py b/Tests/test_imagefontctl.py index bea532b05..24c7b871a 100644 --- a/Tests/test_imagefontctl.py +++ b/Tests/test_imagefontctl.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, ImageDraw, ImageFont @@ -11,7 +12,7 @@ FONT_PATH = "Tests/fonts/DejaVuSans/DejaVuSans.ttf" pytestmark = skip_unless_feature("raqm") -def test_english(): +def test_english() -> None: # smoke test, this should not fail ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) im = Image.new(mode="RGB", size=(300, 100)) @@ -19,7 +20,7 @@ def test_english(): draw.text((0, 0), "TEST", font=ttf, fill=500, direction="ltr") -def test_complex_text(): +def test_complex_text() -> None: ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) im = Image.new(mode="RGB", size=(300, 100)) @@ -30,7 +31,7 @@ def test_complex_text(): assert_image_similar_tofile(im, target, 0.5) -def test_y_offset(): +def test_y_offset() -> None: ttf = ImageFont.truetype("Tests/fonts/NotoNastaliqUrdu-Regular.ttf", FONT_SIZE) im = Image.new(mode="RGB", size=(300, 100)) @@ -41,7 +42,7 @@ def test_y_offset(): assert_image_similar_tofile(im, target, 1.7) -def test_complex_unicode_text(): +def test_complex_unicode_text() -> None: ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) im = Image.new(mode="RGB", size=(300, 100)) @@ -61,7 +62,7 @@ def test_complex_unicode_text(): assert_image_similar_tofile(im, target, 2.33) -def test_text_direction_rtl(): +def test_text_direction_rtl() -> None: ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) im = Image.new(mode="RGB", size=(300, 100)) @@ -72,7 +73,7 @@ def test_text_direction_rtl(): assert_image_similar_tofile(im, target, 0.5) -def test_text_direction_ltr(): +def test_text_direction_ltr() -> None: ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) im = Image.new(mode="RGB", size=(300, 100)) @@ -83,7 +84,7 @@ def test_text_direction_ltr(): assert_image_similar_tofile(im, target, 0.5) -def test_text_direction_rtl2(): +def test_text_direction_rtl2() -> None: ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) im = Image.new(mode="RGB", size=(300, 100)) @@ -94,7 +95,7 @@ def test_text_direction_rtl2(): assert_image_similar_tofile(im, target, 0.5) -def test_text_direction_ttb(): +def test_text_direction_ttb() -> None: ttf = ImageFont.truetype("Tests/fonts/NotoSansJP-Regular.otf", FONT_SIZE) im = Image.new(mode="RGB", size=(100, 300)) @@ -109,7 +110,7 @@ def test_text_direction_ttb(): assert_image_similar_tofile(im, target, 2.8) -def test_text_direction_ttb_stroke(): +def test_text_direction_ttb_stroke() -> None: ttf = ImageFont.truetype("Tests/fonts/NotoSansJP-Regular.otf", 50) im = Image.new(mode="RGB", size=(100, 300)) @@ -132,7 +133,7 @@ def test_text_direction_ttb_stroke(): assert_image_similar_tofile(im, target, 19.4) -def test_ligature_features(): +def test_ligature_features() -> None: ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) im = Image.new(mode="RGB", size=(300, 100)) @@ -145,7 +146,7 @@ def test_ligature_features(): assert liga_bbox == (0, 4, 13, 19) -def test_kerning_features(): +def test_kerning_features() -> None: ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) im = Image.new(mode="RGB", size=(300, 100)) @@ -156,7 +157,7 @@ def test_kerning_features(): assert_image_similar_tofile(im, target, 0.5) -def test_arabictext_features(): +def test_arabictext_features() -> None: ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) im = Image.new(mode="RGB", size=(300, 100)) @@ -173,7 +174,7 @@ def test_arabictext_features(): assert_image_similar_tofile(im, target, 0.5) -def test_x_max_and_y_offset(): +def test_x_max_and_y_offset() -> None: ttf = ImageFont.truetype("Tests/fonts/ArefRuqaa-Regular.ttf", 40) im = Image.new(mode="RGB", size=(50, 100)) @@ -184,7 +185,7 @@ def test_x_max_and_y_offset(): assert_image_similar_tofile(im, target, 0.5) -def test_language(): +def test_language() -> None: ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) im = Image.new(mode="RGB", size=(300, 100)) @@ -207,7 +208,9 @@ def test_language(): ), ids=("None", "ltr", "rtl2", "rtl", "ttb"), ) -def test_getlength(mode, text, direction, expected): +def test_getlength( + mode: str, text: str, direction: str | None, expected: float +) -> None: ttf = ImageFont.truetype(FONT_PATH, FONT_SIZE) im = Image.new(mode, (1, 1), 0) d = ImageDraw.Draw(im) @@ -229,7 +232,7 @@ def test_getlength(mode, text, direction, expected): ("i" + ("\u030C" * 15) + "i", "i" + "\u032C" * 15 + "i", "\u035Cii", "i\u0305i"), ids=("caron-above", "caron-below", "double-breve", "overline"), ) -def test_getlength_combine(mode, direction, text): +def test_getlength_combine(mode: str, direction: str, text: str) -> None: if text == "i\u0305i" and direction == "ttb": pytest.skip("fails with this font") @@ -249,7 +252,7 @@ def test_getlength_combine(mode, direction, text): @pytest.mark.parametrize("anchor", ("lt", "mm", "rb", "sm")) -def test_anchor_ttb(anchor): +def test_anchor_ttb(anchor: str) -> None: text = "f" path = f"Tests/images/test_anchor_ttb_{text}_{anchor}.png" f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 120) @@ -305,7 +308,9 @@ combine_tests = ( @pytest.mark.parametrize( "name, text, anchor, dir, epsilon", combine_tests, ids=[r[0] for r in combine_tests] ) -def test_combine(name, text, dir, anchor, epsilon): +def test_combine( + name: str, text: str, dir: str | None, anchor: str | None, epsilon: float +) -> None: path = f"Tests/images/test_combine_{name}.png" f = ImageFont.truetype("Tests/fonts/NotoSans-Regular.ttf", 48) @@ -336,7 +341,7 @@ def test_combine(name, text, dir, anchor, epsilon): ("rm", "right"), # pass with getsize ), ) -def test_combine_multiline(anchor, align): +def test_combine_multiline(anchor: str, align: str) -> None: # test that multiline text uses getlength, not getsize or getbbox path = f"Tests/images/test_combine_multiline_{anchor}_{align}.png" @@ -354,7 +359,7 @@ def test_combine_multiline(anchor, align): assert_image_similar_tofile(im, path, 0.015) -def test_anchor_invalid_ttb(): +def test_anchor_invalid_ttb() -> None: font = ImageFont.truetype(FONT_PATH, FONT_SIZE) im = Image.new("RGB", (100, 100), "white") d = ImageDraw.Draw(im) diff --git a/Tests/test_imagefontpil.py b/Tests/test_imagefontpil.py index 21b4dee3c..3b1c14b4e 100644 --- a/Tests/test_imagefontpil.py +++ b/Tests/test_imagefontpil.py @@ -1,17 +1,27 @@ from __future__ import annotations + +import struct +from io import BytesIO + import pytest -from PIL import Image, ImageDraw, ImageFont, features +from PIL import Image, ImageDraw, ImageFont, _util, features from .helper import assert_image_equal_tofile -pytestmark = pytest.mark.skipif( - features.check_module("freetype2"), - reason="PILfont superseded if FreeType is supported", -) +original_core = ImageFont.core -def test_default_font(): +def setup_module() -> None: + if features.check_module("freetype2"): + ImageFont.core = _util.DeferredError(ImportError) + + +def teardown_module() -> None: + ImageFont.core = original_core + + +def test_default_font() -> None: # Arrange txt = 'This is a "better than nothing" default font.' im = Image.new(mode="RGB", size=(300, 100)) @@ -25,12 +35,12 @@ def test_default_font(): assert_image_equal_tofile(im, "Tests/images/default_font.png") -def test_size_without_freetype(): +def test_size_without_freetype() -> None: with pytest.raises(ImportError): ImageFont.load_default(size=14) -def test_unicode(): +def test_unicode() -> None: # should not segfault, should return UnicodeDecodeError # issue #2826 font = ImageFont.load_default() @@ -38,9 +48,31 @@ def test_unicode(): font.getbbox("’") -def test_textbbox(): +def test_textbbox() -> None: im = Image.new("RGB", (200, 200)) d = ImageDraw.Draw(im) default_font = ImageFont.load_default() assert d.textlength("test", font=default_font) == 24 assert d.textbbox((0, 0), "test", font=default_font) == (0, 0, 24, 11) + + +def test_decompression_bomb() -> None: + glyph = struct.pack(">hhhhhhhhhh", 1, 0, 0, 0, 256, 256, 0, 0, 256, 256) + fp = BytesIO(b"PILfont\n\nDATA\n" + glyph * 256) + + font = ImageFont.ImageFont() + font._load_pilfont_data(fp, Image.new("L", (256, 256))) + with pytest.raises(Image.DecompressionBombError): + font.getmask("A" * 1_000_000) + + +@pytest.mark.timeout(4) +def test_oom() -> None: + glyph = struct.pack( + ">hhhhhhhhhh", 1, 0, -32767, -32767, 32767, 32767, -32767, -32767, 32767, 32767 + ) + fp = BytesIO(b"PILfont\n\nDATA\n" + glyph * 256) + + font = ImageFont.ImageFont() + font._load_pilfont_data(fp, Image.new("L", (1, 1))) + font.getmask("A" * 1_000_000) diff --git a/Tests/test_imagegrab.py b/Tests/test_imagegrab.py index b7683ec18..e23adeb70 100644 --- a/Tests/test_imagegrab.py +++ b/Tests/test_imagegrab.py @@ -1,4 +1,5 @@ from __future__ import annotations + import os import shutil import subprocess @@ -19,7 +20,7 @@ class TestImageGrab: @pytest.mark.skipif( sys.platform not in ("win32", "darwin"), reason="requires Windows or macOS" ) - def test_grab(self): + def test_grab(self) -> None: ImageGrab.grab() ImageGrab.grab(include_layered_windows=True) ImageGrab.grab(all_screens=True) @@ -28,7 +29,7 @@ class TestImageGrab: assert im.size == (40, 60) @skip_unless_feature("xcb") - def test_grab_x11(self): + def test_grab_x11(self) -> None: try: if sys.platform not in ("win32", "darwin"): ImageGrab.grab() @@ -38,7 +39,7 @@ class TestImageGrab: pytest.skip(str(e)) @pytest.mark.skipif(Image.core.HAVE_XCB, reason="tests missing XCB") - def test_grab_no_xcb(self): + def test_grab_no_xcb(self) -> None: if sys.platform not in ("win32", "darwin") and not shutil.which( "gnome-screenshot" ): @@ -51,12 +52,12 @@ class TestImageGrab: assert str(e.value).startswith("Pillow was built without XCB support") @skip_unless_feature("xcb") - def test_grab_invalid_xdisplay(self): + def test_grab_invalid_xdisplay(self) -> None: with pytest.raises(OSError) as e: ImageGrab.grab(xdisplay="error.test:0.0") assert str(e.value).startswith("X connection failed") - def test_grabclipboard(self): + def test_grabclipboard(self) -> None: if sys.platform == "darwin": subprocess.call(["screencapture", "-cx"]) elif sys.platform == "win32": @@ -81,8 +82,9 @@ $bmp = New-Object Drawing.Bitmap 200, 200 ImageGrab.grabclipboard() @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") - def test_grabclipboard_file(self): + def test_grabclipboard_file(self) -> None: p = subprocess.Popen(["powershell", "-command", "-"], stdin=subprocess.PIPE) + assert p.stdin is not None p.stdin.write(rb'Set-Clipboard -Path "Tests\images\hopper.gif"') p.communicate() @@ -91,8 +93,9 @@ $bmp = New-Object Drawing.Bitmap 200, 200 assert os.path.samefile(im[0], "Tests/images/hopper.gif") @pytest.mark.skipif(sys.platform != "win32", reason="Windows only") - def test_grabclipboard_png(self): + def test_grabclipboard_png(self) -> None: p = subprocess.Popen(["powershell", "-command", "-"], stdin=subprocess.PIPE) + assert p.stdin is not None p.stdin.write( rb"""$bytes = [System.IO.File]::ReadAllBytes("Tests\images\hopper.png") $ms = new-object System.IO.MemoryStream(, $bytes) @@ -112,9 +115,21 @@ $ms = new-object System.IO.MemoryStream(, $bytes) reason="Linux with wl-clipboard only", ) @pytest.mark.parametrize("ext", ("gif", "png", "ico")) - def test_grabclipboard_wl_clipboard(self, ext): + def test_grabclipboard_wl_clipboard(self, ext: str) -> None: image_path = "Tests/images/hopper." + ext with open(image_path, "rb") as fp: subprocess.call(["wl-copy"], stdin=fp) im = ImageGrab.grabclipboard() assert_image_equal_tofile(im, image_path) + + @pytest.mark.skipif( + ( + sys.platform != "linux" + or not all(shutil.which(cmd) for cmd in ("wl-paste", "wl-copy")) + ), + reason="Linux with wl-clipboard only", + ) + @pytest.mark.parametrize("arg", ("text", "--clear")) + def test_grabclipboard_wl_clipboard_errors(self, arg: str) -> None: + subprocess.call(["wl-copy", arg]) + assert ImageGrab.grabclipboard() is None diff --git a/Tests/test_imagemath.py b/Tests/test_imagemath.py index 22de86c7c..a21e2307d 100644 --- a/Tests/test_imagemath.py +++ b/Tests/test_imagemath.py @@ -1,15 +1,16 @@ from __future__ import annotations + import pytest from PIL import Image, ImageMath -def pixel(im): - if hasattr(im, "im"): - return f"{im.mode} {repr(im.getpixel((0, 0)))}" +def pixel(im: Image.Image | int) -> str | int: if isinstance(im, int): return int(im) # hack to deal with booleans + return f"{im.mode} {repr(im.getpixel((0, 0)))}" + A = Image.new("L", (1, 1), 1) B = Image.new("L", (1, 1), 2) @@ -23,7 +24,7 @@ B2 = B.resize((2, 2)) images = {"A": A, "B": B, "F": F, "I": I} -def test_sanity(): +def test_sanity() -> None: assert ImageMath.eval("1") == 1 assert ImageMath.eval("1+A", A=2) == 3 assert pixel(ImageMath.eval("A+B", A=A, B=B)) == "I 3" @@ -32,7 +33,7 @@ def test_sanity(): assert pixel(ImageMath.eval("int(float(A)+B)", images)) == "I 3" -def test_ops(): +def test_ops() -> None: assert pixel(ImageMath.eval("-A", images)) == "I -1" assert pixel(ImageMath.eval("+B", images)) == "L 2" @@ -59,41 +60,51 @@ def test_ops(): "(lambda: (lambda: exec('pass'))())()", ), ) -def test_prevent_exec(expression): +def test_prevent_exec(expression: str) -> None: with pytest.raises(ValueError): ImageMath.eval(expression) -def test_logical(): +def test_prevent_double_underscores() -> None: + with pytest.raises(ValueError): + ImageMath.eval("1", {"__": None}) + + +def test_prevent_builtins() -> None: + with pytest.raises(ValueError): + ImageMath.eval("(lambda: exec('exit()'))()", {"exec": None}) + + +def test_logical() -> None: assert pixel(ImageMath.eval("not A", images)) == 0 assert pixel(ImageMath.eval("A and B", images)) == "L 2" assert pixel(ImageMath.eval("A or B", images)) == "L 1" -def test_convert(): +def test_convert() -> None: assert pixel(ImageMath.eval("convert(A+B, 'L')", images)) == "L 3" assert pixel(ImageMath.eval("convert(A+B, '1')", images)) == "1 0" assert pixel(ImageMath.eval("convert(A+B, 'RGB')", images)) == "RGB (3, 3, 3)" -def test_compare(): +def test_compare() -> None: assert pixel(ImageMath.eval("min(A, B)", images)) == "I 1" assert pixel(ImageMath.eval("max(A, B)", images)) == "I 2" assert pixel(ImageMath.eval("A == 1", images)) == "I 1" assert pixel(ImageMath.eval("A == 2", images)) == "I 0" -def test_one_image_larger(): +def test_one_image_larger() -> None: assert pixel(ImageMath.eval("A+B", A=A2, B=B)) == "I 3" assert pixel(ImageMath.eval("A+B", A=A, B=B2)) == "I 3" -def test_abs(): +def test_abs() -> None: assert pixel(ImageMath.eval("abs(A)", A=A)) == "I 1" assert pixel(ImageMath.eval("abs(B)", B=B)) == "I 2" -def test_binary_mod(): +def test_binary_mod() -> None: assert pixel(ImageMath.eval("A%A", A=A)) == "I 0" assert pixel(ImageMath.eval("B%B", B=B)) == "I 0" assert pixel(ImageMath.eval("A%B", A=A, B=B)) == "I 1" @@ -102,90 +113,90 @@ def test_binary_mod(): assert pixel(ImageMath.eval("Z%B", B=B, Z=Z)) == "I 0" -def test_bitwise_invert(): +def test_bitwise_invert() -> None: assert pixel(ImageMath.eval("~Z", Z=Z)) == "I -1" assert pixel(ImageMath.eval("~A", A=A)) == "I -2" assert pixel(ImageMath.eval("~B", B=B)) == "I -3" -def test_bitwise_and(): +def test_bitwise_and() -> None: assert pixel(ImageMath.eval("Z&Z", A=A, Z=Z)) == "I 0" assert pixel(ImageMath.eval("Z&A", A=A, Z=Z)) == "I 0" assert pixel(ImageMath.eval("A&Z", A=A, Z=Z)) == "I 0" assert pixel(ImageMath.eval("A&A", A=A, Z=Z)) == "I 1" -def test_bitwise_or(): +def test_bitwise_or() -> None: assert pixel(ImageMath.eval("Z|Z", A=A, Z=Z)) == "I 0" assert pixel(ImageMath.eval("Z|A", A=A, Z=Z)) == "I 1" assert pixel(ImageMath.eval("A|Z", A=A, Z=Z)) == "I 1" assert pixel(ImageMath.eval("A|A", A=A, Z=Z)) == "I 1" -def test_bitwise_xor(): +def test_bitwise_xor() -> None: assert pixel(ImageMath.eval("Z^Z", A=A, Z=Z)) == "I 0" assert pixel(ImageMath.eval("Z^A", A=A, Z=Z)) == "I 1" assert pixel(ImageMath.eval("A^Z", A=A, Z=Z)) == "I 1" assert pixel(ImageMath.eval("A^A", A=A, Z=Z)) == "I 0" -def test_bitwise_leftshift(): +def test_bitwise_leftshift() -> None: assert pixel(ImageMath.eval("Z<<0", Z=Z)) == "I 0" assert pixel(ImageMath.eval("Z<<1", Z=Z)) == "I 0" assert pixel(ImageMath.eval("A<<0", A=A)) == "I 1" assert pixel(ImageMath.eval("A<<1", A=A)) == "I 2" -def test_bitwise_rightshift(): +def test_bitwise_rightshift() -> None: assert pixel(ImageMath.eval("Z>>0", Z=Z)) == "I 0" assert pixel(ImageMath.eval("Z>>1", Z=Z)) == "I 0" assert pixel(ImageMath.eval("A>>0", A=A)) == "I 1" assert pixel(ImageMath.eval("A>>1", A=A)) == "I 0" -def test_logical_eq(): +def test_logical_eq() -> None: assert pixel(ImageMath.eval("A==A", A=A)) == "I 1" assert pixel(ImageMath.eval("B==B", B=B)) == "I 1" assert pixel(ImageMath.eval("A==B", A=A, B=B)) == "I 0" assert pixel(ImageMath.eval("B==A", A=A, B=B)) == "I 0" -def test_logical_ne(): +def test_logical_ne() -> None: assert pixel(ImageMath.eval("A!=A", A=A)) == "I 0" assert pixel(ImageMath.eval("B!=B", B=B)) == "I 0" assert pixel(ImageMath.eval("A!=B", A=A, B=B)) == "I 1" assert pixel(ImageMath.eval("B!=A", A=A, B=B)) == "I 1" -def test_logical_lt(): +def test_logical_lt() -> None: assert pixel(ImageMath.eval("A None: assert pixel(ImageMath.eval("A<=A", A=A)) == "I 1" assert pixel(ImageMath.eval("B<=B", B=B)) == "I 1" assert pixel(ImageMath.eval("A<=B", A=A, B=B)) == "I 1" assert pixel(ImageMath.eval("B<=A", A=A, B=B)) == "I 0" -def test_logical_gt(): +def test_logical_gt() -> None: assert pixel(ImageMath.eval("A>A", A=A)) == "I 0" assert pixel(ImageMath.eval("B>B", B=B)) == "I 0" assert pixel(ImageMath.eval("A>B", A=A, B=B)) == "I 0" assert pixel(ImageMath.eval("B>A", A=A, B=B)) == "I 1" -def test_logical_ge(): +def test_logical_ge() -> None: assert pixel(ImageMath.eval("A>=A", A=A)) == "I 1" assert pixel(ImageMath.eval("B>=B", B=B)) == "I 1" assert pixel(ImageMath.eval("A>=B", A=A, B=B)) == "I 0" assert pixel(ImageMath.eval("B>=A", A=A, B=B)) == "I 1" -def test_logical_equal(): +def test_logical_equal() -> None: assert pixel(ImageMath.eval("equal(A, A)", A=A)) == "I 1" assert pixel(ImageMath.eval("equal(B, B)", B=B)) == "I 1" assert pixel(ImageMath.eval("equal(Z, Z)", Z=Z)) == "I 1" @@ -194,7 +205,7 @@ def test_logical_equal(): assert pixel(ImageMath.eval("equal(A, Z)", A=A, Z=Z)) == "I 0" -def test_logical_not_equal(): +def test_logical_not_equal() -> None: assert pixel(ImageMath.eval("notequal(A, A)", A=A)) == "I 0" assert pixel(ImageMath.eval("notequal(B, B)", B=B)) == "I 0" assert pixel(ImageMath.eval("notequal(Z, Z)", Z=Z)) == "I 0" diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index ec55aadf9..32615cf0e 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -1,5 +1,8 @@ # Test the ImageMorphology functionality from __future__ import annotations + +from pathlib import Path + import pytest from PIL import Image, ImageMorph, _imagingmorph @@ -7,7 +10,7 @@ from PIL import Image, ImageMorph, _imagingmorph from .helper import assert_image_equal_tofile, hopper -def string_to_img(image_string): +def string_to_img(image_string: str) -> Image.Image: """Turn a string image representation into a binary image""" rows = [s for s in image_string.replace(" ", "").split("\n") if len(s)] height = len(rows) @@ -35,7 +38,7 @@ A = string_to_img( ) -def img_to_string(im): +def img_to_string(im: Image.Image) -> str: """Turn a (small) binary image into a string representation""" chars = ".1" width, height = im.size @@ -45,31 +48,22 @@ def img_to_string(im): ) -def img_string_normalize(im): +def img_string_normalize(im: str) -> str: return img_to_string(string_to_img(im)) -def assert_img_equal_img_string(a, b_string): +def assert_img_equal_img_string(a: Image.Image, b_string: str) -> None: assert img_to_string(a) == img_string_normalize(b_string) -def test_str_to_img(): +def test_str_to_img() -> None: assert_image_equal_tofile(A, "Tests/images/morph_a.png") -def create_lut(): - for op in ("corner", "dilation4", "dilation8", "erosion4", "erosion8", "edge"): - lb = ImageMorph.LutBuilder(op_name=op) - lut = lb.build_lut() - with open(f"Tests/images/{op}.lut", "wb") as f: - f.write(lut) - - -# create_lut() @pytest.mark.parametrize( "op", ("corner", "dilation4", "dilation8", "erosion4", "erosion8", "edge") ) -def test_lut(op): +def test_lut(op: str) -> None: lb = ImageMorph.LutBuilder(op_name=op) assert lb.get_lut() is None @@ -78,21 +72,22 @@ def test_lut(op): assert lut == bytearray(f.read()) -def test_no_operator_loaded(): +def test_no_operator_loaded() -> None: + im = Image.new("L", (1, 1)) mop = ImageMorph.MorphOp() with pytest.raises(Exception) as e: - mop.apply(None) + mop.apply(im) assert str(e.value) == "No operator loaded" with pytest.raises(Exception) as e: - mop.match(None) + mop.match(im) assert str(e.value) == "No operator loaded" with pytest.raises(Exception) as e: - mop.save_lut(None) + mop.save_lut("") assert str(e.value) == "No operator loaded" # Test the named patterns -def test_erosion8(): +def test_erosion8() -> None: # erosion8 mop = ImageMorph.MorphOp(op_name="erosion8") count, Aout = mop.apply(A) @@ -111,7 +106,7 @@ def test_erosion8(): ) -def test_dialation8(): +def test_dialation8() -> None: # dialation8 mop = ImageMorph.MorphOp(op_name="dilation8") count, Aout = mop.apply(A) @@ -130,7 +125,7 @@ def test_dialation8(): ) -def test_erosion4(): +def test_erosion4() -> None: # erosion4 mop = ImageMorph.MorphOp(op_name="dilation4") count, Aout = mop.apply(A) @@ -149,7 +144,7 @@ def test_erosion4(): ) -def test_edge(): +def test_edge() -> None: # edge mop = ImageMorph.MorphOp(op_name="edge") count, Aout = mop.apply(A) @@ -168,7 +163,7 @@ def test_edge(): ) -def test_corner(): +def test_corner() -> None: # Create a corner detector pattern mop = ImageMorph.MorphOp(patterns=["1:(... ... ...)->0", "4:(00. 01. ...)->1"]) count, Aout = mop.apply(A) @@ -196,7 +191,7 @@ def test_corner(): assert tuple(coords) == ((2, 2), (4, 2), (2, 4), (4, 4)) -def test_mirroring(): +def test_mirroring() -> None: # Test 'M' for mirroring mop = ImageMorph.MorphOp(patterns=["1:(... ... ...)->0", "M:(00. 01. ...)->1"]) count, Aout = mop.apply(A) @@ -215,7 +210,7 @@ def test_mirroring(): ) -def test_negate(): +def test_negate() -> None: # Test 'N' for negate mop = ImageMorph.MorphOp(patterns=["1:(... ... ...)->0", "N:(00. 01. ...)->1"]) count, Aout = mop.apply(A) @@ -234,7 +229,7 @@ def test_negate(): ) -def test_incorrect_mode(): +def test_incorrect_mode() -> None: im = hopper("RGB") mop = ImageMorph.MorphOp(op_name="erosion8") @@ -249,7 +244,7 @@ def test_incorrect_mode(): assert str(e.value) == "Image mode must be L" -def test_add_patterns(): +def test_add_patterns() -> None: # Arrange lb = ImageMorph.LutBuilder(op_name="corner") assert lb.patterns == ["1:(... ... ...)->0", "4:(00. 01. ...)->1"] @@ -267,12 +262,12 @@ def test_add_patterns(): ] -def test_unknown_pattern(): +def test_unknown_pattern() -> None: with pytest.raises(Exception): ImageMorph.LutBuilder(op_name="unknown") -def test_pattern_syntax_error(): +def test_pattern_syntax_error() -> None: # Arrange lb = ImageMorph.LutBuilder(op_name="corner") new_patterns = ["a pattern with a syntax error"] @@ -284,7 +279,7 @@ def test_pattern_syntax_error(): assert str(e.value) == 'Syntax error in pattern "a pattern with a syntax error"' -def test_load_invalid_mrl(): +def test_load_invalid_mrl() -> None: # Arrange invalid_mrl = "Tests/images/hopper.png" mop = ImageMorph.MorphOp() @@ -295,7 +290,7 @@ def test_load_invalid_mrl(): assert str(e.value) == "Wrong size operator file!" -def test_roundtrip_mrl(tmp_path): +def test_roundtrip_mrl(tmp_path: Path) -> None: # Arrange tempfile = str(tmp_path / "temp.mrl") mop = ImageMorph.MorphOp(op_name="corner") @@ -309,7 +304,7 @@ def test_roundtrip_mrl(tmp_path): assert mop.lut == initial_lut -def test_set_lut(): +def test_set_lut() -> None: # Arrange lb = ImageMorph.LutBuilder(op_name="corner") lut = lb.build_lut() @@ -322,7 +317,7 @@ def test_set_lut(): assert mop.lut == lut -def test_wrong_mode(): +def test_wrong_mode() -> None: lut = ImageMorph.LutBuilder(op_name="corner").build_lut() imrgb = Image.new("RGB", (10, 10)) iml = Image.new("L", (10, 10)) diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 7980bead0..d6bdaf450 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, ImageDraw, ImageOps, ImageStat, features @@ -12,8 +13,12 @@ from .helper import ( ) -class Deformer: - def getmesh(self, im): +class Deformer(ImageOps.SupportsGetMesh): + def getmesh( + self, im: Image.Image + ) -> list[ + tuple[tuple[int, int, int, int], tuple[int, int, int, int, int, int, int, int]] + ]: x, y = im.size return [((0, 0, x, y), (0, 0, x, 0, x, y, y, 0))] @@ -21,7 +26,7 @@ class Deformer: deformer = Deformer() -def test_sanity(): +def test_sanity() -> None: ImageOps.autocontrast(hopper("L")) ImageOps.autocontrast(hopper("RGB")) @@ -83,7 +88,7 @@ def test_sanity(): ImageOps.exif_transpose(hopper("RGB")) -def test_1pxfit(): +def test_1pxfit() -> None: # Division by zero in equalize if image is 1 pixel high newimg = ImageOps.fit(hopper("RGB").resize((1, 1)), (35, 35)) assert newimg.size == (35, 35) @@ -95,7 +100,7 @@ def test_1pxfit(): assert newimg.size == (35, 35) -def test_fit_same_ratio(): +def test_fit_same_ratio() -> None: # The ratio for this image is 1000.0 / 755 = 1.3245033112582782 # If the ratios are not acknowledged to be the same, # and Pillow attempts to adjust the width to @@ -107,13 +112,13 @@ def test_fit_same_ratio(): @pytest.mark.parametrize("new_size", ((256, 256), (512, 256), (256, 512))) -def test_contain(new_size): +def test_contain(new_size: tuple[int, int]) -> None: im = hopper() new_im = ImageOps.contain(im, new_size) assert new_im.size == (256, 256) -def test_contain_round(): +def test_contain_round() -> None: im = Image.new("1", (43, 63), 1) new_im = ImageOps.contain(im, (5, 7)) assert new_im.width == 5 @@ -131,13 +136,13 @@ def test_contain_round(): ("hopper.png", (256, 256)), # square ), ) -def test_cover(image_name, expected_size): +def test_cover(image_name: str, expected_size: tuple[int, int]) -> None: with Image.open("Tests/images/" + image_name) as im: new_im = ImageOps.cover(im, (256, 256)) assert new_im.size == expected_size -def test_pad(): +def test_pad() -> None: # Same ratio im = hopper() new_size = (im.width * 2, im.height * 2) @@ -157,7 +162,7 @@ def test_pad(): ) -def test_pad_round(): +def test_pad_round() -> None: im = Image.new("1", (1, 1), 1) new_im = ImageOps.pad(im, (4, 1)) assert new_im.load()[2, 0] == 1 @@ -167,7 +172,7 @@ def test_pad_round(): @pytest.mark.parametrize("mode", ("P", "PA")) -def test_palette(mode): +def test_palette(mode: str) -> None: im = hopper(mode) # Expand @@ -181,7 +186,7 @@ def test_palette(mode): ) -def test_pil163(): +def test_pil163() -> None: # Division by zero in equalize if < 255 pixels in image (@PIL163) i = hopper("RGB").resize((15, 16)) @@ -191,7 +196,7 @@ def test_pil163(): ImageOps.equalize(i.convert("RGB")) -def test_scale(): +def test_scale() -> None: # Test the scaling function i = hopper("L").resize((50, 50)) @@ -209,7 +214,7 @@ def test_scale(): @pytest.mark.parametrize("border", (10, (1, 2, 3, 4))) -def test_expand_palette(border): +def test_expand_palette(border: int | tuple[int, int, int, int]) -> None: with Image.open("Tests/images/p_16.tga") as im: im_expanded = ImageOps.expand(im, border, (255, 0, 0)) @@ -235,7 +240,7 @@ def test_expand_palette(border): assert_image_equal(im_cropped, im) -def test_colorize_2color(): +def test_colorize_2color() -> None: # Test the colorizing function with 2-color functionality # Open test image (256px by 10px, black to white) @@ -269,7 +274,7 @@ def test_colorize_2color(): ) -def test_colorize_2color_offset(): +def test_colorize_2color_offset() -> None: # Test the colorizing function with 2-color functionality and offset # Open test image (256px by 10px, black to white) @@ -305,7 +310,7 @@ def test_colorize_2color_offset(): ) -def test_colorize_3color_offset(): +def test_colorize_3color_offset() -> None: # Test the colorizing function with 3-color functionality and offset # Open test image (256px by 10px, black to white) @@ -358,14 +363,14 @@ def test_colorize_3color_offset(): ) -def test_exif_transpose(): +def test_exif_transpose() -> None: exts = [".jpg"] if features.check("webp") and features.check("webp_anim"): exts.append(".webp") for ext in exts: with Image.open("Tests/images/hopper" + ext) as base_im: - def check(orientation_im): + def check(orientation_im: Image.Image) -> None: for im in [ orientation_im, orientation_im.copy(), @@ -375,6 +380,7 @@ def test_exif_transpose(): else: original_exif = im.info["exif"] transposed_im = ImageOps.exif_transpose(im) + assert transposed_im is not None assert_image_similar(base_im, transposed_im, 17) if orientation_im is base_im: assert "exif" not in im.info @@ -386,6 +392,7 @@ def test_exif_transpose(): # Repeat the operation to test that it does not keep transposing transposed_im2 = ImageOps.exif_transpose(transposed_im) + assert transposed_im2 is not None assert_image_equal(transposed_im2, transposed_im) check(base_im) @@ -401,6 +408,7 @@ def test_exif_transpose(): assert im.getexif()[0x0112] == 3 transposed_im = ImageOps.exif_transpose(im) + assert transposed_im is not None assert 0x0112 not in transposed_im.getexif() transposed_im._reload_exif() @@ -413,16 +421,18 @@ def test_exif_transpose(): assert im.getexif()[0x0112] == 3 transposed_im = ImageOps.exif_transpose(im) + assert transposed_im is not None assert 0x0112 not in transposed_im.getexif() # Orientation set directly on Image.Exif im = hopper() im.getexif()[0x0112] = 3 transposed_im = ImageOps.exif_transpose(im) + assert transposed_im is not None assert 0x0112 not in transposed_im.getexif() -def test_exif_transpose_in_place(): +def test_exif_transpose_in_place() -> None: with Image.open("Tests/images/orientation_rectangle.jpg") as im: assert im.size == (2, 1) assert im.getexif()[0x0112] == 8 @@ -434,24 +444,24 @@ def test_exif_transpose_in_place(): assert_image_equal(im, expected) -def test_autocontrast_unsupported_mode(): +def test_autocontrast_unsupported_mode() -> None: im = Image.new("RGBA", (1, 1)) with pytest.raises(OSError): ImageOps.autocontrast(im) -def test_autocontrast_cutoff(): +def test_autocontrast_cutoff() -> None: # Test the cutoff argument of autocontrast with Image.open("Tests/images/bw_gradient.png") as img: - def autocontrast(cutoff): + def autocontrast(cutoff: int | tuple[int, int]): return ImageOps.autocontrast(img, cutoff).histogram() assert autocontrast(10) == autocontrast((10, 10)) assert autocontrast(10) != autocontrast((1, 10)) -def test_autocontrast_mask_toy_input(): +def test_autocontrast_mask_toy_input() -> None: # Test the mask argument of autocontrast with Image.open("Tests/images/bw_gradient.png") as img: rect_mask = Image.new("L", img.size, 0) @@ -470,7 +480,7 @@ def test_autocontrast_mask_toy_input(): assert ImageStat.Stat(result_nomask).median == [128] -def test_autocontrast_mask_real_input(): +def test_autocontrast_mask_real_input() -> None: # Test the autocontrast with a rectangular mask with Image.open("Tests/images/iptc.jpg") as img: rect_mask = Image.new("L", img.size, 0) @@ -485,20 +495,20 @@ def test_autocontrast_mask_real_input(): assert result_nomask != result assert_tuple_approx_equal( ImageStat.Stat(result, mask=rect_mask).median, - [195, 202, 184], + (195, 202, 184), threshold=2, msg="autocontrast with mask pixel incorrect", ) assert_tuple_approx_equal( ImageStat.Stat(result_nomask).median, - [119, 106, 79], + (119, 106, 79), threshold=2, msg="autocontrast without mask pixel incorrect", ) -def test_autocontrast_preserve_tone(): - def autocontrast(mode, preserve_tone): +def test_autocontrast_preserve_tone() -> None: + def autocontrast(mode: str, preserve_tone: bool) -> list[int]: im = hopper(mode) return ImageOps.autocontrast(im, preserve_tone=preserve_tone).histogram() @@ -506,7 +516,7 @@ def test_autocontrast_preserve_tone(): assert autocontrast("L", True) == autocontrast("L", False) -def test_autocontrast_preserve_gradient(): +def test_autocontrast_preserve_gradient() -> None: gradient = Image.linear_gradient("L") # test with a grayscale gradient that extends to 0,255. @@ -532,7 +542,7 @@ def test_autocontrast_preserve_gradient(): @pytest.mark.parametrize( "color", ((255, 255, 255), (127, 255, 0), (127, 127, 127), (0, 0, 0)) ) -def test_autocontrast_preserve_one_color(color): +def test_autocontrast_preserve_one_color(color: tuple[int, int, int]) -> None: img = Image.new("RGB", (10, 10), color) # single color images shouldn't change diff --git a/Tests/test_imageops_usm.py b/Tests/test_imageops_usm.py index 84d3a6950..c15907a55 100644 --- a/Tests/test_imageops_usm.py +++ b/Tests/test_imageops_usm.py @@ -1,11 +1,14 @@ from __future__ import annotations + +from typing import Generator + import pytest from PIL import Image, ImageFilter @pytest.fixture -def test_images(): +def test_images() -> Generator[dict[str, Image.Image], None, None]: ims = { "im": Image.open("Tests/images/hopper.ppm"), "snakes": Image.open("Tests/images/color_snakes.png"), @@ -17,7 +20,7 @@ def test_images(): im.close() -def test_filter_api(test_images): +def test_filter_api(test_images: dict[str, Image.Image]) -> None: im = test_images["im"] test_filter = ImageFilter.GaussianBlur(2.0) @@ -25,13 +28,13 @@ def test_filter_api(test_images): assert i.mode == "RGB" assert i.size == (128, 128) - test_filter = ImageFilter.UnsharpMask(2.0, 125, 8) - i = im.filter(test_filter) + test_filter2 = ImageFilter.UnsharpMask(2.0, 125, 8) + i = im.filter(test_filter2) assert i.mode == "RGB" assert i.size == (128, 128) -def test_usm_formats(test_images): +def test_usm_formats(test_images: dict[str, Image.Image]) -> None: im = test_images["im"] usm = ImageFilter.UnsharpMask @@ -49,7 +52,7 @@ def test_usm_formats(test_images): im.convert("YCbCr").filter(usm) -def test_blur_formats(test_images): +def test_blur_formats(test_images: dict[str, Image.Image]) -> None: im = test_images["im"] blur = ImageFilter.GaussianBlur @@ -67,7 +70,7 @@ def test_blur_formats(test_images): im.convert("YCbCr").filter(blur) -def test_usm_accuracy(test_images): +def test_usm_accuracy(test_images: dict[str, Image.Image]) -> None: snakes = test_images["snakes"] src = snakes.convert("RGB") @@ -76,7 +79,7 @@ def test_usm_accuracy(test_images): assert i.tobytes() == src.tobytes() -def test_blur_accuracy(test_images): +def test_blur_accuracy(test_images: dict[str, Image.Image]) -> None: snakes = test_images["snakes"] i = snakes.filter(ImageFilter.GaussianBlur(0.4)) diff --git a/Tests/test_imagepalette.py b/Tests/test_imagepalette.py index e5b59b74a..8e2db15aa 100644 --- a/Tests/test_imagepalette.py +++ b/Tests/test_imagepalette.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import Image, ImagePalette @@ -6,19 +9,19 @@ from PIL import Image, ImagePalette from .helper import assert_image_equal, assert_image_equal_tofile -def test_sanity(): +def test_sanity() -> None: palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3) assert len(palette.colors) == 256 -def test_reload(): +def test_reload() -> None: with Image.open("Tests/images/hopper.gif") as im: original = im.copy() im.palette.dirty = 1 assert_image_equal(im.convert("RGB"), original.convert("RGB")) -def test_getcolor(): +def test_getcolor() -> None: palette = ImagePalette.ImagePalette() assert len(palette.palette) == 0 assert len(palette.colors) == 0 @@ -45,7 +48,7 @@ def test_getcolor(): palette.getcolor("unknown") -def test_getcolor_rgba_color_rgb_palette(): +def test_getcolor_rgba_color_rgb_palette() -> None: palette = ImagePalette.ImagePalette("RGB") # Opaque RGBA colors are converted @@ -64,7 +67,7 @@ def test_getcolor_rgba_color_rgb_palette(): (255, ImagePalette.ImagePalette("RGB", list(range(256)) * 3)), ], ) -def test_getcolor_not_special(index, palette): +def test_getcolor_not_special(index: int, palette: ImagePalette.ImagePalette) -> None: im = Image.new("P", (1, 1)) # Do not use transparency index as a new color @@ -78,7 +81,7 @@ def test_getcolor_not_special(index, palette): assert index2 not in (index, index1) -def test_file(tmp_path): +def test_file(tmp_path: Path) -> None: palette = ImagePalette.ImagePalette("RGB", list(range(256)) * 3) f = str(tmp_path / "temp.lut") @@ -96,7 +99,7 @@ def test_file(tmp_path): assert p.palette == palette.tobytes() -def test_make_linear_lut(): +def test_make_linear_lut() -> None: # Arrange black = 0 white = 255 @@ -112,7 +115,7 @@ def test_make_linear_lut(): assert lut[i] == i -def test_make_linear_lut_not_yet_implemented(): +def test_make_linear_lut_not_yet_implemented() -> None: # Update after FIXME # Arrange black = 1 @@ -123,7 +126,7 @@ def test_make_linear_lut_not_yet_implemented(): ImagePalette.make_linear_lut(black, white) -def test_make_gamma_lut(): +def test_make_gamma_lut() -> None: # Arrange exp = 5 @@ -141,7 +144,7 @@ def test_make_gamma_lut(): assert lut[255] == 255 -def test_rawmode_valueerrors(tmp_path): +def test_rawmode_valueerrors(tmp_path: Path) -> None: # Arrange palette = ImagePalette.raw("RGB", list(range(256)) * 3) @@ -155,7 +158,7 @@ def test_rawmode_valueerrors(tmp_path): palette.save(f) -def test_getdata(): +def test_getdata() -> None: # Arrange data_in = list(range(256)) * 3 palette = ImagePalette.ImagePalette("RGB", data_in) @@ -167,7 +170,7 @@ def test_getdata(): assert mode == "RGB" -def test_rawmode_getdata(): +def test_rawmode_getdata() -> None: # Arrange data_in = list(range(256)) * 3 palette = ImagePalette.raw("RGB", data_in) @@ -180,7 +183,7 @@ def test_rawmode_getdata(): assert data_in == data_out -def test_2bit_palette(tmp_path): +def test_2bit_palette(tmp_path: Path) -> None: # issue #2258, 2 bit palettes are corrupted. outfile = str(tmp_path / "temp.png") @@ -192,6 +195,6 @@ def test_2bit_palette(tmp_path): assert_image_equal_tofile(img, outfile) -def test_invalid_palette(): +def test_invalid_palette() -> None: with pytest.raises(OSError): ImagePalette.load("Tests/images/hopper.jpg") diff --git a/Tests/test_imagepath.py b/Tests/test_imagepath.py index ac3ea3281..9487560af 100644 --- a/Tests/test_imagepath.py +++ b/Tests/test_imagepath.py @@ -1,14 +1,16 @@ from __future__ import annotations + import array import math import struct +from typing import Sequence import pytest from PIL import Image, ImagePath -def test_path(): +def test_path() -> None: p = ImagePath.Path(list(range(10))) # sequence interface @@ -56,7 +58,9 @@ def test_path(): ImagePath.Path((0, 1)), ), ) -def test_path_constructors(coords): +def test_path_constructors( + coords: Sequence[float] | array.array[float] | ImagePath.Path, +) -> None: # Arrange / Act p = ImagePath.Path(coords) @@ -74,7 +78,9 @@ def test_path_constructors(coords): [[0.0, 1.0]], ), ) -def test_invalid_path_constructors(coords): +def test_invalid_path_constructors( + coords: tuple[str, str] | Sequence[Sequence[int]] +) -> None: # Act with pytest.raises(ValueError) as e: ImagePath.Path(coords) @@ -92,7 +98,7 @@ def test_invalid_path_constructors(coords): [0, 1, 2], ), ) -def test_path_odd_number_of_coordinates(coords): +def test_path_odd_number_of_coordinates(coords: Sequence[int]) -> None: # Act with pytest.raises(ValueError) as e: ImagePath.Path(coords) @@ -110,7 +116,9 @@ def test_path_odd_number_of_coordinates(coords): (1, (0.0, 0.0, 0.0, 0.0)), ], ) -def test_getbbox(coords, expected): +def test_getbbox( + coords: int | list[int], expected: tuple[float, float, float, float] +) -> None: # Arrange p = ImagePath.Path(coords) @@ -118,7 +126,7 @@ def test_getbbox(coords, expected): assert p.getbbox() == expected -def test_getbbox_no_args(): +def test_getbbox_no_args() -> None: # Arrange p = ImagePath.Path([0, 1, 2, 3]) @@ -134,7 +142,7 @@ def test_getbbox_no_args(): (list(range(6)), [(0.0, 3.0), (4.0, 9.0), (8.0, 15.0)]), ], ) -def test_map(coords, expected): +def test_map(coords: int | list[int], expected: list[tuple[float, float]]) -> None: # Arrange p = ImagePath.Path(coords) @@ -146,7 +154,7 @@ def test_map(coords, expected): assert list(p) == expected -def test_transform(): +def test_transform() -> None: # Arrange p = ImagePath.Path([0, 1, 2, 3]) theta = math.pi / 15 @@ -164,7 +172,7 @@ def test_transform(): ] -def test_transform_with_wrap(): +def test_transform_with_wrap() -> None: # Arrange p = ImagePath.Path([0, 1, 2, 3]) theta = math.pi / 15 @@ -183,7 +191,7 @@ def test_transform_with_wrap(): ] -def test_overflow_segfault(): +def test_overflow_segfault() -> None: # Some Pythons fail getting the argument as an integer, and it falls # through to the sequence. Seeing this on 32-bit Windows. with pytest.raises((TypeError, MemoryError)): @@ -197,12 +205,12 @@ def test_overflow_segfault(): class Evil: - def __init__(self): + def __init__(self) -> None: self.corrupt = Image.core.path(0x4000000000000000) - def __getitem__(self, i): + def __getitem__(self, i: int) -> bytes: x = self.corrupt[i] return struct.pack("dd", x[0], x[1]) - def __setitem__(self, i, x): + def __setitem__(self, i: int, x: bytes) -> None: self.corrupt[i] = struct.unpack("dd", x) diff --git a/Tests/test_imageqt.py b/Tests/test_imageqt.py index 41d247f42..88ad1f9ee 100644 --- a/Tests/test_imageqt.py +++ b/Tests/test_imageqt.py @@ -1,4 +1,5 @@ from __future__ import annotations + import warnings import pytest @@ -15,7 +16,7 @@ if ImageQt.qt_is_installed: from PIL.ImageQt import qRgba -def test_rgb(): +def test_rgb() -> None: # from https://doc.qt.io/archives/qt-4.8/qcolor.html # typedef QRgb # An ARGB quadruplet on the format #AARRGGBB, @@ -27,7 +28,7 @@ def test_rgb(): assert qRgb(0, 0, 0) == qRgba(0, 0, 0, 255) - def checkrgb(r, g, b): + def checkrgb(r: int, g: int, b: int) -> None: val = ImageQt.rgb(r, g, b) val = val % 2**24 # drop the alpha assert val >> 16 == r @@ -40,7 +41,7 @@ def test_rgb(): checkrgb(0, 0, 255) -def test_image(): +def test_image() -> None: modes = ["1", "RGB", "RGBA", "L", "P"] qt_format = ImageQt.QImage.Format if ImageQt.qt_version == "6" else ImageQt.QImage if hasattr(qt_format, "Format_Grayscale16"): # Qt 5.13+ @@ -54,6 +55,6 @@ def test_image(): assert_image_similar(roundtripped_im, im, 1) -def test_closed_file(): +def test_closed_file() -> None: with warnings.catch_warnings(): ImageQt.ImageQt("Tests/images/hopper.gif") diff --git a/Tests/test_imagesequence.py b/Tests/test_imagesequence.py index 6d71e4d87..7f3a3d141 100644 --- a/Tests/test_imagesequence.py +++ b/Tests/test_imagesequence.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import Image, ImageSequence, TiffImagePlugin @@ -6,7 +9,7 @@ from PIL import Image, ImageSequence, TiffImagePlugin from .helper import assert_image_equal, hopper, skip_unless_feature -def test_sanity(tmp_path): +def test_sanity(tmp_path: Path) -> None: test_file = str(tmp_path / "temp.im") im = hopper("RGB") @@ -23,10 +26,10 @@ def test_sanity(tmp_path): assert index == 1 with pytest.raises(AttributeError): - ImageSequence.Iterator(0) + ImageSequence.Iterator(0) # type: ignore[arg-type] -def test_iterator(): +def test_iterator() -> None: with Image.open("Tests/images/multipage.tiff") as im: i = ImageSequence.Iterator(im) for index in range(0, im.n_frames): @@ -37,14 +40,14 @@ def test_iterator(): next(i) -def test_iterator_min_frame(): +def test_iterator_min_frame() -> None: with Image.open("Tests/images/hopper.psd") as im: i = ImageSequence.Iterator(im) for index in range(1, im.n_frames): assert i[index] == next(i) -def _test_multipage_tiff(): +def _test_multipage_tiff() -> None: with Image.open("Tests/images/multipage.tiff") as im: for index, frame in enumerate(ImageSequence.Iterator(im)): frame.load() @@ -52,29 +55,30 @@ def _test_multipage_tiff(): frame.convert("RGB") -def test_tiff(): +def test_tiff() -> None: _test_multipage_tiff() @skip_unless_feature("libtiff") -def test_libtiff(): +def test_libtiff() -> None: TiffImagePlugin.READ_LIBTIFF = True _test_multipage_tiff() TiffImagePlugin.READ_LIBTIFF = False -def test_consecutive(): +def test_consecutive() -> None: with Image.open("Tests/images/multipage.tiff") as im: first_frame = None for frame in ImageSequence.Iterator(im): if first_frame is None: first_frame = frame.copy() + assert first_frame is not None for frame in ImageSequence.Iterator(im): assert_image_equal(frame, first_frame) break -def test_palette_mmap(): +def test_palette_mmap() -> None: # Using mmap in ImageFile can require to reload the palette. with Image.open("Tests/images/multipage-mmap.tiff") as im: color1 = im.getpalette()[:3] @@ -83,7 +87,7 @@ def test_palette_mmap(): assert color1 == color2 -def test_all_frames(): +def test_all_frames() -> None: # Test a single image with Image.open("Tests/images/iss634.gif") as im: ims = ImageSequence.all_frames(im) diff --git a/Tests/test_imageshow.py b/Tests/test_imageshow.py index 761d28d30..4e9291fbb 100644 --- a/Tests/test_imageshow.py +++ b/Tests/test_imageshow.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from typing import Any + import pytest from PIL import Image, ImageShow @@ -6,12 +9,12 @@ from PIL import Image, ImageShow from .helper import hopper, is_win32, on_ci -def test_sanity(): +def test_sanity() -> None: dir(Image) dir(ImageShow) -def test_register(): +def test_register() -> None: # Test registering a viewer that is not a class ImageShow.register("not a class") @@ -23,9 +26,9 @@ def test_register(): "order", [-1, 0], ) -def test_viewer_show(order): +def test_viewer_show(order: int) -> None: class TestViewer(ImageShow.Viewer): - def show_image(self, image, **options): + def show_image(self, image: Image.Image, **options: Any) -> bool: self.methodCalled = True return True @@ -47,12 +50,12 @@ def test_viewer_show(order): reason="Only run on CIs; hangs on Windows CIs", ) @pytest.mark.parametrize("mode", ("1", "I;16", "LA", "RGB", "RGBA")) -def test_show(mode): +def test_show(mode: str) -> None: im = hopper(mode) assert ImageShow.show(im) -def test_show_without_viewers(): +def test_show_without_viewers() -> None: viewers = ImageShow._viewers ImageShow._viewers = [] @@ -62,24 +65,25 @@ def test_show_without_viewers(): ImageShow._viewers = viewers -def test_viewer(): +def test_viewer() -> None: viewer = ImageShow.Viewer() - assert viewer.get_format(None) is None + im = Image.new("L", (1, 1)) + assert viewer.get_format(im) is None with pytest.raises(NotImplementedError): - viewer.get_command(None) + viewer.get_command("") @pytest.mark.parametrize("viewer", ImageShow._viewers) -def test_viewers(viewer): +def test_viewers(viewer: ImageShow.Viewer) -> None: try: viewer.get_command("test.jpg") except NotImplementedError: pass -def test_ipythonviewer(): +def test_ipythonviewer() -> None: pytest.importorskip("IPython", reason="IPython not installed") for viewer in ImageShow._viewers: if isinstance(viewer, ImageShow.IPythonViewer): diff --git a/Tests/test_imagestat.py b/Tests/test_imagestat.py index 7b56b89cc..b1c1306c1 100644 --- a/Tests/test_imagestat.py +++ b/Tests/test_imagestat.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image, ImageStat @@ -6,7 +7,7 @@ from PIL import Image, ImageStat from .helper import hopper -def test_sanity(): +def test_sanity() -> None: im = hopper() st = ImageStat.Stat(im) @@ -30,7 +31,7 @@ def test_sanity(): ImageStat.Stat(1) -def test_hopper(): +def test_hopper() -> None: im = hopper() st = ImageStat.Stat(im) @@ -43,7 +44,7 @@ def test_hopper(): assert st.sum[2] == 1563008 -def test_constant(): +def test_constant() -> None: im = Image.new("L", (128, 128), 128) st = ImageStat.Stat(im) diff --git a/Tests/test_imagetk.py b/Tests/test_imagetk.py index bb20fbb6f..b607b8c43 100644 --- a/Tests/test_imagetk.py +++ b/Tests/test_imagetk.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image @@ -22,7 +23,7 @@ TK_MODES = ("1", "L", "P", "RGB", "RGBA") pytestmark = pytest.mark.skipif(not HAS_TK, reason="Tk not installed") -def setup_module(): +def setup_module() -> None: try: # setup tk tk.Frame() @@ -33,7 +34,7 @@ def setup_module(): pytest.skip(f"TCL Error: {v}") -def test_kw(): +def test_kw() -> None: TEST_JPG = "Tests/images/hopper.jpg" TEST_PNG = "Tests/images/hopper.png" with Image.open(TEST_JPG) as im1: @@ -56,7 +57,7 @@ def test_kw(): @pytest.mark.parametrize("mode", TK_MODES) -def test_photoimage(mode): +def test_photoimage(mode: str) -> None: # test as image: im = hopper(mode) @@ -70,7 +71,7 @@ def test_photoimage(mode): assert_image_equal(reloaded, im.convert("RGBA")) -def test_photoimage_apply_transparency(): +def test_photoimage_apply_transparency() -> None: with Image.open("Tests/images/pil123p.png") as im: im_tk = ImageTk.PhotoImage(im) reloaded = ImageTk.getimage(im_tk) @@ -78,7 +79,7 @@ def test_photoimage_apply_transparency(): @pytest.mark.parametrize("mode", TK_MODES) -def test_photoimage_blank(mode): +def test_photoimage_blank(mode: str) -> None: # test a image using mode/size: im_tk = ImageTk.PhotoImage(mode, (100, 100)) @@ -90,7 +91,7 @@ def test_photoimage_blank(mode): assert_image_equal(reloaded.convert(mode), im) -def test_bitmapimage(): +def test_bitmapimage() -> None: im = hopper("1") # this should not crash diff --git a/Tests/test_imagewin.py b/Tests/test_imagewin.py index 6927eedcf..b43c31b52 100644 --- a/Tests/test_imagewin.py +++ b/Tests/test_imagewin.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import ImageWin @@ -7,10 +8,10 @@ from .helper import hopper, is_win32 class TestImageWin: - def test_sanity(self): + def test_sanity(self) -> None: dir(ImageWin) - def test_hdc(self): + def test_hdc(self) -> None: # Arrange dc = 50 @@ -21,7 +22,7 @@ class TestImageWin: # Assert assert dc2 == 50 - def test_hwnd(self): + def test_hwnd(self) -> None: # Arrange wnd = 50 @@ -35,7 +36,7 @@ class TestImageWin: @pytest.mark.skipif(not is_win32(), reason="Windows only") class TestImageWinDib: - def test_dib_image(self): + def test_dib_image(self) -> None: # Arrange im = hopper() @@ -45,7 +46,7 @@ class TestImageWinDib: # Assert assert dib.size == im.size - def test_dib_mode_string(self): + def test_dib_mode_string(self) -> None: # Arrange mode = "RGBA" size = (128, 128) @@ -56,7 +57,7 @@ class TestImageWinDib: # Assert assert dib.size == (128, 128) - def test_dib_paste(self): + def test_dib_paste(self) -> None: # Arrange im = hopper() @@ -70,7 +71,7 @@ class TestImageWinDib: # Assert assert dib.size == (128, 128) - def test_dib_paste_bbox(self): + def test_dib_paste_bbox(self) -> None: # Arrange im = hopper() bbox = (0, 0, 10, 10) @@ -85,7 +86,7 @@ class TestImageWinDib: # Assert assert dib.size == (128, 128) - def test_dib_frombytes_tobytes_roundtrip(self): + def test_dib_frombytes_tobytes_roundtrip(self) -> None: # Arrange # Make two different DIB images im = hopper() diff --git a/Tests/test_imagewin_pointers.py b/Tests/test_imagewin_pointers.py index bd154335a..f59ee7284 100644 --- a/Tests/test_imagewin_pointers.py +++ b/Tests/test_imagewin_pointers.py @@ -1,5 +1,7 @@ from __future__ import annotations + from io import BytesIO +from pathlib import Path from PIL import Image, ImageWin @@ -68,7 +70,7 @@ if is_win32(): ] CreateDIBSection.restype = ctypes.wintypes.HBITMAP - def serialize_dib(bi, pixels): + def serialize_dib(bi, pixels) -> bytearray: bf = BITMAPFILEHEADER() bf.bfType = 0x4D42 bf.bfOffBits = ctypes.sizeof(bf) + bi.biSize @@ -82,7 +84,7 @@ if is_win32(): memcpy(bp + bf.bfOffBits, pixels, bi.biSizeImage) return bytearray(buf) - def test_pointer(tmp_path): + def test_pointer(tmp_path: Path) -> None: im = hopper() (width, height) = im.size opath = str(tmp_path / "temp.png") diff --git a/Tests/test_lib_image.py b/Tests/test_lib_image.py index 92cad4ac1..31548bbc9 100644 --- a/Tests/test_lib_image.py +++ b/Tests/test_lib_image.py @@ -1,10 +1,11 @@ from __future__ import annotations + import pytest from PIL import Image -def test_setmode(): +def test_setmode() -> None: im = Image.new("L", (1, 1), 255) im.im.setmode("1") assert im.im.getpixel((0, 0)) == 255 diff --git a/Tests/test_lib_pack.py b/Tests/test_lib_pack.py index 1293f7628..629a6dc7a 100644 --- a/Tests/test_lib_pack.py +++ b/Tests/test_lib_pack.py @@ -1,4 +1,5 @@ from __future__ import annotations + import sys import pytest @@ -9,7 +10,13 @@ X = 255 class TestLibPack: - def assert_pack(self, mode, rawmode, data, *pixels): + def assert_pack( + self, + mode: str, + rawmode: str, + data: int | bytes, + *pixels: int | float | tuple[int, ...], + ) -> None: """ data - either raw bytes with data or just number of bytes in rawmode. """ @@ -23,7 +30,7 @@ class TestLibPack: assert data == im.tobytes("raw", rawmode) - def test_1(self): + def test_1(self) -> None: self.assert_pack("1", "1", b"\x01", 0, 0, 0, 0, 0, 0, 0, X) self.assert_pack("1", "1;I", b"\x01", X, X, X, X, X, X, X, 0) self.assert_pack("1", "1;R", b"\x01", X, 0, 0, 0, 0, 0, 0, 0) @@ -36,29 +43,29 @@ class TestLibPack: self.assert_pack("1", "L", b"\xff\x00\x00\xff\x00\x00", X, 0, 0, X, 0, 0) - def test_L(self): + def test_L(self) -> None: self.assert_pack("L", "L", 1, 1, 2, 3, 4) self.assert_pack("L", "L;16", b"\x00\xc6\x00\xaf", 198, 175) self.assert_pack("L", "L;16B", b"\xc6\x00\xaf\x00", 198, 175) - def test_LA(self): + def test_LA(self) -> None: self.assert_pack("LA", "LA", 2, (1, 2), (3, 4), (5, 6)) self.assert_pack("LA", "LA;L", 2, (1, 4), (2, 5), (3, 6)) - def test_La(self): + def test_La(self) -> None: self.assert_pack("La", "La", 2, (1, 2), (3, 4), (5, 6)) - def test_P(self): + def test_P(self) -> None: self.assert_pack("P", "P;1", b"\xe4", 1, 1, 1, 0, 0, 255, 0, 0) self.assert_pack("P", "P;2", b"\xe4", 3, 2, 1, 0) self.assert_pack("P", "P;4", b"\x02\xef", 0, 2, 14, 15) self.assert_pack("P", "P", 1, 1, 2, 3, 4) - def test_PA(self): + def test_PA(self) -> None: self.assert_pack("PA", "PA", 2, (1, 2), (3, 4), (5, 6)) self.assert_pack("PA", "PA;L", 2, (1, 4), (2, 5), (3, 6)) - def test_RGB(self): + def test_RGB(self) -> None: self.assert_pack("RGB", "RGB", 3, (1, 2, 3), (4, 5, 6), (7, 8, 9)) self.assert_pack( "RGB", "RGBX", b"\x01\x02\x03\xff\x05\x06\x07\xff", (1, 2, 3), (5, 6, 7) @@ -78,7 +85,7 @@ class TestLibPack: self.assert_pack("RGB", "G", 1, (9, 1, 9), (9, 2, 9), (9, 3, 9)) self.assert_pack("RGB", "B", 1, (9, 9, 1), (9, 9, 2), (9, 9, 3)) - def test_RGBA(self): + def test_RGBA(self) -> None: self.assert_pack("RGBA", "RGBA", 4, (1, 2, 3, 4), (5, 6, 7, 8), (9, 10, 11, 12)) self.assert_pack( "RGBA", "RGBA;L", 4, (1, 4, 7, 10), (2, 5, 8, 11), (3, 6, 9, 12) @@ -100,12 +107,12 @@ class TestLibPack: self.assert_pack("RGBA", "B", 1, (6, 7, 1, 9), (6, 7, 2, 0), (6, 7, 3, 9)) self.assert_pack("RGBA", "A", 1, (6, 7, 0, 1), (6, 7, 0, 2), (0, 7, 0, 3)) - def test_RGBa(self): + def test_RGBa(self) -> None: self.assert_pack("RGBa", "RGBa", 4, (1, 2, 3, 4), (5, 6, 7, 8), (9, 10, 11, 12)) self.assert_pack("RGBa", "BGRa", 4, (3, 2, 1, 4), (7, 6, 5, 8), (11, 10, 9, 12)) self.assert_pack("RGBa", "aBGR", 4, (4, 3, 2, 1), (8, 7, 6, 5), (12, 11, 10, 9)) - def test_RGBX(self): + def test_RGBX(self) -> None: self.assert_pack("RGBX", "RGBX", 4, (1, 2, 3, 4), (5, 6, 7, 8), (9, 10, 11, 12)) self.assert_pack( "RGBX", "RGBX;L", 4, (1, 4, 7, 10), (2, 5, 8, 11), (3, 6, 9, 12) @@ -133,7 +140,7 @@ class TestLibPack: self.assert_pack("RGBX", "B", 1, (6, 7, 1, 9), (6, 7, 2, 0), (6, 7, 3, 9)) self.assert_pack("RGBX", "X", 1, (6, 7, 0, 1), (6, 7, 0, 2), (0, 7, 0, 3)) - def test_CMYK(self): + def test_CMYK(self) -> None: self.assert_pack("CMYK", "CMYK", 4, (1, 2, 3, 4), (5, 6, 7, 8), (9, 10, 11, 12)) self.assert_pack( "CMYK", @@ -148,7 +155,7 @@ class TestLibPack: ) self.assert_pack("CMYK", "K", 1, (6, 7, 0, 1), (6, 7, 0, 2), (0, 7, 0, 3)) - def test_YCbCr(self): + def test_YCbCr(self) -> None: self.assert_pack("YCbCr", "YCbCr", 3, (1, 2, 3), (4, 5, 6), (7, 8, 9)) self.assert_pack("YCbCr", "YCbCr;L", 3, (1, 4, 7), (2, 5, 8), (3, 6, 9)) self.assert_pack( @@ -171,19 +178,19 @@ class TestLibPack: self.assert_pack("YCbCr", "Cb", 1, (6, 1, 8, 9), (6, 2, 8, 9), (6, 3, 8, 9)) self.assert_pack("YCbCr", "Cr", 1, (6, 7, 1, 9), (6, 7, 2, 0), (6, 7, 3, 9)) - def test_LAB(self): + def test_LAB(self) -> None: self.assert_pack("LAB", "LAB", 3, (1, 130, 131), (4, 133, 134), (7, 136, 137)) self.assert_pack("LAB", "L", 1, (1, 9, 9), (2, 9, 9), (3, 9, 9)) self.assert_pack("LAB", "A", 1, (9, 1, 9), (9, 2, 9), (9, 3, 9)) self.assert_pack("LAB", "B", 1, (9, 9, 1), (9, 9, 2), (9, 9, 3)) - def test_HSV(self): + def test_HSV(self) -> None: self.assert_pack("HSV", "HSV", 3, (1, 2, 3), (4, 5, 6), (7, 8, 9)) self.assert_pack("HSV", "H", 1, (1, 9, 9), (2, 9, 9), (3, 9, 9)) self.assert_pack("HSV", "S", 1, (9, 1, 9), (9, 2, 9), (9, 3, 9)) self.assert_pack("HSV", "V", 1, (9, 9, 1), (9, 9, 2), (9, 9, 3)) - def test_I(self): + def test_I(self) -> None: self.assert_pack("I", "I;16B", 2, 0x0102, 0x0304) self.assert_pack( "I", "I;32S", b"\x83\x00\x00\x01\x01\x00\x00\x83", 0x01000083, -2097151999 @@ -208,10 +215,10 @@ class TestLibPack: 0x01000083, ) - def test_I16(self): + def test_I16(self) -> None: self.assert_pack("I;16N", "I;16N", 2, 0x0201, 0x0403, 0x0605) - def test_F_float(self): + def test_F_float(self) -> None: self.assert_pack("F", "F;32F", 4, 1.539989614439558e-36, 4.063216068939723e-34) if sys.byteorder == "little": @@ -227,7 +234,13 @@ class TestLibPack: class TestLibUnpack: - def assert_unpack(self, mode, rawmode, data, *pixels): + def assert_unpack( + self, + mode: str, + rawmode: str, + data: int | bytes, + *pixels: int | float | tuple[int, ...], + ) -> None: """ data - either raw bytes with data or just number of bytes in rawmode. """ @@ -240,7 +253,7 @@ class TestLibUnpack: for x, pixel in enumerate(pixels): assert pixel == im.getpixel((x, 0)) - def test_1(self): + def test_1(self) -> None: self.assert_unpack("1", "1", b"\x01", 0, 0, 0, 0, 0, 0, 0, X) self.assert_unpack("1", "1;I", b"\x01", X, X, X, X, X, X, X, 0) self.assert_unpack("1", "1;R", b"\x01", X, 0, 0, 0, 0, 0, 0, 0) @@ -253,7 +266,7 @@ class TestLibUnpack: self.assert_unpack("1", "1;8", b"\x00\x01\x02\xff", 0, X, X, X) - def test_L(self): + def test_L(self) -> None: self.assert_unpack("L", "L;2", b"\xe4", 255, 170, 85, 0) self.assert_unpack("L", "L;2I", b"\xe4", 0, 85, 170, 255) self.assert_unpack("L", "L;2R", b"\xe4", 0, 170, 85, 255) @@ -272,14 +285,14 @@ class TestLibUnpack: self.assert_unpack("L", "L;16", b"\x00\xc6\x00\xaf", 198, 175) self.assert_unpack("L", "L;16B", b"\xc6\x00\xaf\x00", 198, 175) - def test_LA(self): + def test_LA(self) -> None: self.assert_unpack("LA", "LA", 2, (1, 2), (3, 4), (5, 6)) self.assert_unpack("LA", "LA;L", 2, (1, 4), (2, 5), (3, 6)) - def test_La(self): + def test_La(self) -> None: self.assert_unpack("La", "La", 2, (1, 2), (3, 4), (5, 6)) - def test_P(self): + def test_P(self) -> None: self.assert_unpack("P", "P;1", b"\xe4", 1, 1, 1, 0, 0, 1, 0, 0) self.assert_unpack("P", "P;2", b"\xe4", 3, 2, 1, 0) # erroneous? @@ -290,11 +303,11 @@ class TestLibUnpack: self.assert_unpack("P", "P", 1, 1, 2, 3, 4) self.assert_unpack("P", "P;R", 1, 128, 64, 192, 32) - def test_PA(self): + def test_PA(self) -> None: self.assert_unpack("PA", "PA", 2, (1, 2), (3, 4), (5, 6)) self.assert_unpack("PA", "PA;L", 2, (1, 4), (2, 5), (3, 6)) - def test_RGB(self): + def test_RGB(self) -> None: self.assert_unpack("RGB", "RGB", 3, (1, 2, 3), (4, 5, 6), (7, 8, 9)) self.assert_unpack("RGB", "RGB;L", 3, (1, 4, 7), (2, 5, 8), (3, 6, 9)) self.assert_unpack("RGB", "RGB;R", 3, (128, 64, 192), (32, 160, 96)) @@ -345,14 +358,14 @@ class TestLibUnpack: "RGB", "CMYK", 4, (250, 249, 248), (242, 241, 240), (234, 233, 233) ) - def test_BGR(self): + def test_BGR(self) -> None: self.assert_unpack("BGR;15", "BGR;15", 3, (8, 131, 0), (24, 0, 8), (41, 131, 8)) self.assert_unpack( "BGR;16", "BGR;16", 3, (8, 64, 0), (24, 129, 0), (41, 194, 0) ) self.assert_unpack("BGR;24", "BGR;24", 3, (1, 2, 3), (4, 5, 6), (7, 8, 9)) - def test_RGBA(self): + def test_RGBA(self) -> None: self.assert_unpack("RGBA", "LA", 2, (1, 1, 1, 2), (3, 3, 3, 4), (5, 5, 5, 6)) self.assert_unpack( "RGBA", "LA;16B", 4, (1, 1, 1, 3), (5, 5, 5, 7), (9, 9, 9, 11) @@ -521,7 +534,7 @@ class TestLibUnpack: "RGBA", "A;16N", 2, (0, 0, 0, 1), (0, 0, 0, 3), (0, 0, 0, 5) ) - def test_RGBa(self): + def test_RGBa(self) -> None: self.assert_unpack( "RGBa", "RGBa", 4, (1, 2, 3, 4), (5, 6, 7, 8), (9, 10, 11, 12) ) @@ -535,7 +548,7 @@ class TestLibUnpack: "RGBa", "aBGR", 4, (4, 3, 2, 1), (8, 7, 6, 5), (12, 11, 10, 9) ) - def test_RGBX(self): + def test_RGBX(self) -> None: self.assert_unpack("RGBX", "RGB", 3, (1, 2, 3, X), (4, 5, 6, X), (7, 8, 9, X)) self.assert_unpack("RGBX", "RGB;L", 3, (1, 4, 7, X), (2, 5, 8, X), (3, 6, 9, X)) self.assert_unpack("RGBX", "RGB;16B", 6, (1, 3, 5, X), (7, 9, 11, X)) @@ -580,7 +593,7 @@ class TestLibUnpack: self.assert_unpack("RGBX", "B", 1, (0, 0, 1, 0), (0, 0, 2, 0), (0, 0, 3, 0)) self.assert_unpack("RGBX", "X", 1, (0, 0, 0, 1), (0, 0, 0, 2), (0, 0, 0, 3)) - def test_CMYK(self): + def test_CMYK(self) -> None: self.assert_unpack( "CMYK", "CMYK", 4, (1, 2, 3, 4), (5, 6, 7, 8), (9, 10, 11, 12) ) @@ -618,25 +631,25 @@ class TestLibUnpack: "CMYK", "K;I", 1, (0, 0, 0, 254), (0, 0, 0, 253), (0, 0, 0, 252) ) - def test_YCbCr(self): + def test_YCbCr(self) -> None: self.assert_unpack("YCbCr", "YCbCr", 3, (1, 2, 3), (4, 5, 6), (7, 8, 9)) self.assert_unpack("YCbCr", "YCbCr;L", 3, (1, 4, 7), (2, 5, 8), (3, 6, 9)) self.assert_unpack("YCbCr", "YCbCrK", 4, (1, 2, 3), (5, 6, 7), (9, 10, 11)) self.assert_unpack("YCbCr", "YCbCrX", 4, (1, 2, 3), (5, 6, 7), (9, 10, 11)) - def test_LAB(self): + def test_LAB(self) -> None: self.assert_unpack("LAB", "LAB", 3, (1, 130, 131), (4, 133, 134), (7, 136, 137)) self.assert_unpack("LAB", "L", 1, (1, 0, 0), (2, 0, 0), (3, 0, 0)) self.assert_unpack("LAB", "A", 1, (0, 1, 0), (0, 2, 0), (0, 3, 0)) self.assert_unpack("LAB", "B", 1, (0, 0, 1), (0, 0, 2), (0, 0, 3)) - def test_HSV(self): + def test_HSV(self) -> None: self.assert_unpack("HSV", "HSV", 3, (1, 2, 3), (4, 5, 6), (7, 8, 9)) self.assert_unpack("HSV", "H", 1, (1, 0, 0), (2, 0, 0), (3, 0, 0)) self.assert_unpack("HSV", "S", 1, (0, 1, 0), (0, 2, 0), (0, 3, 0)) self.assert_unpack("HSV", "V", 1, (0, 0, 1), (0, 0, 2), (0, 0, 3)) - def test_I(self): + def test_I(self) -> None: self.assert_unpack("I", "I;8", 1, 0x01, 0x02, 0x03, 0x04) self.assert_unpack("I", "I;8S", b"\x01\x83", 1, -125) self.assert_unpack("I", "I;16", 2, 0x0201, 0x0403) @@ -677,7 +690,7 @@ class TestLibUnpack: 0x01000083, ) - def test_F_int(self): + def test_F_int(self) -> None: self.assert_unpack("F", "F;8", 1, 0x01, 0x02, 0x03, 0x04) self.assert_unpack("F", "F;8S", b"\x01\x83", 1, -125) self.assert_unpack("F", "F;16", 2, 0x0201, 0x0403) @@ -716,7 +729,7 @@ class TestLibUnpack: 16777348, ) - def test_F_float(self): + def test_F_float(self) -> None: self.assert_unpack( "F", "F;32F", 4, 1.539989614439558e-36, 4.063216068939723e-34 ) @@ -767,7 +780,7 @@ class TestLibUnpack: -1234.5, ) - def test_I16(self): + def test_I16(self) -> None: self.assert_unpack("I;16", "I;16", 2, 0x0201, 0x0403, 0x0605) self.assert_unpack("I;16", "I;16B", 2, 0x0102, 0x0304, 0x0506) self.assert_unpack("I;16B", "I;16B", 2, 0x0102, 0x0304, 0x0506) @@ -784,7 +797,7 @@ class TestLibUnpack: self.assert_unpack("I;16L", "I;16N", 2, 0x0102, 0x0304, 0x0506) self.assert_unpack("I;16N", "I;16N", 2, 0x0102, 0x0304, 0x0506) - def test_CMYK16(self): + def test_CMYK16(self) -> None: self.assert_unpack("CMYK", "CMYK;16L", 8, (2, 4, 6, 8), (10, 12, 14, 16)) self.assert_unpack("CMYK", "CMYK;16B", 8, (1, 3, 5, 7), (9, 11, 13, 15)) if sys.byteorder == "little": @@ -792,7 +805,7 @@ class TestLibUnpack: else: self.assert_unpack("CMYK", "CMYK;16N", 8, (1, 3, 5, 7), (9, 11, 13, 15)) - def test_value_error(self): + def test_value_error(self) -> None: with pytest.raises(ValueError): self.assert_unpack("L", "L", 0, 0) with pytest.raises(ValueError): diff --git a/Tests/test_locale.py b/Tests/test_locale.py index 49b052fa4..1c8b84a2b 100644 --- a/Tests/test_locale.py +++ b/Tests/test_locale.py @@ -1,4 +1,5 @@ from __future__ import annotations + import locale import pytest @@ -23,7 +24,7 @@ from PIL import Image path = "Tests/images/hopper.jpg" -def test_sanity(): +def test_sanity() -> None: with Image.open(path): pass try: diff --git a/Tests/test_main.py b/Tests/test_main.py index a84e61a7b..46259f1dc 100644 --- a/Tests/test_main.py +++ b/Tests/test_main.py @@ -1,10 +1,11 @@ from __future__ import annotations + import os import subprocess import sys -def test_main(): +def test_main() -> None: out = subprocess.check_output([sys.executable, "-m", "PIL"]).decode("utf-8") lines = out.splitlines() assert lines[0] == "-" * 68 diff --git a/Tests/test_map.py b/Tests/test_map.py index 76444f33d..93140f6e5 100644 --- a/Tests/test_map.py +++ b/Tests/test_map.py @@ -1,4 +1,5 @@ from __future__ import annotations + import sys import pytest @@ -6,7 +7,7 @@ import pytest from PIL import Image -def test_overflow(): +def test_overflow() -> None: # There is the potential to overflow comparisons in map.c # if there are > SIZE_MAX bytes in the image or if # the file encodes an offset that makes @@ -24,7 +25,7 @@ def test_overflow(): Image.MAX_IMAGE_PIXELS = max_pixels -def test_tobytes(): +def test_tobytes() -> None: # Note that this image triggers the decompression bomb warning: max_pixels = Image.MAX_IMAGE_PIXELS Image.MAX_IMAGE_PIXELS = None @@ -38,7 +39,7 @@ def test_tobytes(): @pytest.mark.skipif(sys.maxsize <= 2**32, reason="Requires 64-bit system") -def test_ysize(): +def test_ysize() -> None: numpy = pytest.importorskip("numpy", reason="NumPy not installed") # Should not raise 'Integer overflow in ysize' diff --git a/Tests/test_mode_i16.py b/Tests/test_mode_i16.py index 3e17d8dcc..1b01f95ce 100644 --- a/Tests/test_mode_i16.py +++ b/Tests/test_mode_i16.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import Image @@ -8,7 +11,7 @@ from .helper import hopper original = hopper().resize((32, 32)).convert("I") -def verify(im1): +def verify(im1: Image.Image) -> None: im2 = original.copy() assert im1.size == im2.size pix1 = im1.load() @@ -24,7 +27,7 @@ def verify(im1): @pytest.mark.parametrize("mode", ("L", "I;16", "I;16B", "I;16L", "I")) -def test_basic(tmp_path, mode): +def test_basic(tmp_path: Path, mode: str) -> None: # PIL 1.1 has limited support for 16-bit image data. Check that # create/copy/transform and save works as expected. @@ -74,8 +77,8 @@ def test_basic(tmp_path, mode): assert im_in.getpixel((0, 0)) == min(512, maximum) -def test_tobytes(): - def tobytes(mode): +def test_tobytes() -> None: + def tobytes(mode: str) -> bytes: return Image.new(mode, (1, 1), 1).tobytes() order = 1 if Image._ENDIAN == "<" else -1 @@ -86,7 +89,7 @@ def test_tobytes(): assert tobytes("I") == b"\x01\x00\x00\x00"[::order] -def test_convert(): +def test_convert() -> None: im = original.copy() for mode in ("I;16", "I;16B", "I;16N"): diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index 6f0e99b3f..9f4e6534e 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -1,4 +1,5 @@ from __future__ import annotations + import warnings import pytest @@ -12,8 +13,8 @@ numpy = pytest.importorskip("numpy", reason="NumPy not installed") TEST_IMAGE_SIZE = (10, 10) -def test_numpy_to_image(): - def to_image(dtype, bands=1, boolean=0): +def test_numpy_to_image() -> None: + def to_image(dtype, bands: int = 1, boolean: int = 0) -> Image.Image: if bands == 1: if boolean: data = [0, 255] * 50 @@ -81,7 +82,7 @@ def test_numpy_to_image(): # Based on an erring example at # https://stackoverflow.com/questions/10854903/what-is-causing-dimension-dependent-attributeerror-in-pil-fromarray-function -def test_3d_array(): +def test_3d_array() -> None: size = (5, TEST_IMAGE_SIZE[0], TEST_IMAGE_SIZE[1]) a = numpy.ones(size, dtype=numpy.uint8) assert_image(Image.fromarray(a[1, :, :]), "L", TEST_IMAGE_SIZE) @@ -93,12 +94,12 @@ def test_3d_array(): assert_image(Image.fromarray(a[:, :, 1]), "L", TEST_IMAGE_SIZE) -def test_1d_array(): +def test_1d_array() -> None: a = numpy.ones(5, dtype=numpy.uint8) assert_image(Image.fromarray(a), "L", (1, 5)) -def _test_img_equals_nparray(img, np): +def _test_img_equals_nparray(img: Image.Image, np) -> None: assert len(np.shape) >= 2 np_size = np.shape[1], np.shape[0] assert img.size == np_size @@ -108,14 +109,14 @@ def _test_img_equals_nparray(img, np): assert_deep_equal(px[x, y], np[y, x]) -def test_16bit(): +def test_16bit() -> None: with Image.open("Tests/images/16bit.cropped.tif") as img: np_img = numpy.array(img) _test_img_equals_nparray(img, np_img) assert np_img.dtype == numpy.dtype(" None: # Test that 1-bit arrays convert to numpy and back # See: https://github.com/python-pillow/Pillow/issues/350 arr = numpy.array([[1, 0, 0, 1, 0], [0, 1, 0, 0, 0]], "u1") @@ -125,7 +126,7 @@ def test_1bit(): numpy.testing.assert_array_equal(arr, arr_back) -def test_save_tiff_uint16(): +def test_save_tiff_uint16() -> None: # Tests that we're getting the pixel value in the right byte order. pixel_value = 0x1234 a = numpy.array( @@ -156,7 +157,7 @@ def test_save_tiff_uint16(): ("HSV", numpy.uint8), ), ) -def test_to_array(mode, dtype): +def test_to_array(mode: str, dtype) -> None: img = hopper(mode) # Resize to non-square @@ -168,7 +169,7 @@ def test_to_array(mode, dtype): assert np_img.dtype == dtype -def test_point_lut(): +def test_point_lut() -> None: # See https://github.com/python-pillow/Pillow/issues/439 data = list(range(256)) * 3 @@ -179,7 +180,7 @@ def test_point_lut(): im.point(lut) -def test_putdata(): +def test_putdata() -> None: # Shouldn't segfault # See https://github.com/python-pillow/Pillow/issues/1008 @@ -206,12 +207,12 @@ def test_putdata(): numpy.float64, ), ) -def test_roundtrip_eye(dtype): +def test_roundtrip_eye(dtype) -> None: arr = numpy.eye(10, dtype=dtype) numpy.testing.assert_array_equal(arr, numpy.array(Image.fromarray(arr))) -def test_zero_size(): +def test_zero_size() -> None: # Shouldn't cause floating point exception # See https://github.com/python-pillow/Pillow/issues/2259 @@ -221,13 +222,13 @@ def test_zero_size(): @skip_unless_feature("libtiff") -def test_load_first(): +def test_load_first() -> None: with Image.open("Tests/images/g4_orientation_5.tif") as im: a = numpy.array(im) assert a.shape == (88, 590) -def test_bool(): +def test_bool() -> None: # https://github.com/python-pillow/Pillow/issues/2044 a = numpy.zeros((10, 2), dtype=bool) a[0][0] = True @@ -236,7 +237,7 @@ def test_bool(): assert im2.getdata()[0] == 255 -def test_no_resource_warning_for_numpy_array(): +def test_no_resource_warning_for_numpy_array() -> None: # https://github.com/python-pillow/Pillow/issues/835 # Arrange from numpy import array diff --git a/Tests/test_pdfparser.py b/Tests/test_pdfparser.py index aeeafb6f1..f6b12cb20 100644 --- a/Tests/test_pdfparser.py +++ b/Tests/test_pdfparser.py @@ -1,4 +1,5 @@ from __future__ import annotations + import time import pytest @@ -18,14 +19,14 @@ from PIL.PdfParser import ( ) -def test_text_encode_decode(): +def test_text_encode_decode() -> None: assert encode_text("abc") == b"\xFE\xFF\x00a\x00b\x00c" assert decode_text(b"\xFE\xFF\x00a\x00b\x00c") == "abc" assert decode_text(b"abc") == "abc" assert decode_text(b"\x1B a \x1C") == "\u02D9 a \u02DD" -def test_indirect_refs(): +def test_indirect_refs() -> None: assert IndirectReference(1, 2) == IndirectReference(1, 2) assert IndirectReference(1, 2) != IndirectReference(1, 3) assert IndirectReference(1, 2) != IndirectObjectDef(1, 2) @@ -36,7 +37,7 @@ def test_indirect_refs(): assert IndirectObjectDef(1, 2) != (1, 2) -def test_parsing(): +def test_parsing() -> None: assert PdfParser.interpret_name(b"Name#23Hash") == b"Name#Hash" assert PdfParser.interpret_name(b"Name#23Hash", as_text=True) == "Name#Hash" assert PdfParser.get_value(b"1 2 R ", 0) == (IndirectReference(1, 2), 5) @@ -94,7 +95,7 @@ def test_parsing(): assert time.strftime("%Y%m%d%H%M%S", getattr(d, name)) == value -def test_pdf_repr(): +def test_pdf_repr() -> None: assert bytes(IndirectReference(1, 2)) == b"1 2 R" assert bytes(IndirectObjectDef(*IndirectReference(1, 2))) == b"1 2 obj" assert bytes(PdfName(b"Name#Hash")) == b"/Name#23Hash" @@ -120,7 +121,7 @@ def test_pdf_repr(): assert pdf_repr(PdfBinary(b"\x90\x1F\xA0")) == b"<901FA0>" -def test_duplicate_xref_entry(): +def test_duplicate_xref_entry() -> None: pdf = PdfParser("Tests/images/duplicate_xref_entry.pdf") assert pdf.xref_table.existing_entries[6][0] == 1197 pdf.close() diff --git a/Tests/test_pickle.py b/Tests/test_pickle.py index eb687b57b..ed415953f 100644 --- a/Tests/test_pickle.py +++ b/Tests/test_pickle.py @@ -1,5 +1,7 @@ from __future__ import annotations + import pickle +from pathlib import Path import pytest @@ -11,7 +13,9 @@ FONT_SIZE = 20 FONT_PATH = "Tests/fonts/DejaVuSans/DejaVuSans.ttf" -def helper_pickle_file(tmp_path, pickle, protocol, test_file, mode): +def helper_pickle_file( + tmp_path: Path, protocol: int, test_file: str, mode: str | None +) -> None: # Arrange with Image.open(test_file) as im: filename = str(tmp_path / "temp.pkl") @@ -28,7 +32,7 @@ def helper_pickle_file(tmp_path, pickle, protocol, test_file, mode): assert im == loaded_im -def helper_pickle_string(pickle, protocol, test_file, mode): +def helper_pickle_string(protocol: int, test_file: str, mode: str | None) -> None: with Image.open(test_file) as im: if mode: im = im.convert(mode) @@ -62,13 +66,15 @@ def helper_pickle_string(pickle, protocol, test_file, mode): ], ) @pytest.mark.parametrize("protocol", range(0, pickle.HIGHEST_PROTOCOL + 1)) -def test_pickle_image(tmp_path, test_file, test_mode, protocol): +def test_pickle_image( + tmp_path: Path, test_file: str, test_mode: str | None, protocol: int +) -> None: # Act / Assert - helper_pickle_string(pickle, protocol, test_file, test_mode) - helper_pickle_file(tmp_path, pickle, protocol, test_file, test_mode) + helper_pickle_string(protocol, test_file, test_mode) + helper_pickle_file(tmp_path, protocol, test_file, test_mode) -def test_pickle_la_mode_with_palette(tmp_path): +def test_pickle_la_mode_with_palette(tmp_path: Path) -> None: # Arrange filename = str(tmp_path / "temp.pkl") with Image.open("Tests/images/hopper.jpg") as im: @@ -87,7 +93,7 @@ def test_pickle_la_mode_with_palette(tmp_path): @skip_unless_feature("webp") -def test_pickle_tell(): +def test_pickle_tell() -> None: # Arrange with Image.open("Tests/images/hopper.webp") as image: # Act: roundtrip @@ -97,7 +103,9 @@ def test_pickle_tell(): assert unpickled_image.tell() == 0 -def helper_assert_pickled_font_images(font1, font2): +def helper_assert_pickled_font_images( + font1: ImageFont.FreeTypeFont, font2: ImageFont.FreeTypeFont +) -> None: # Arrange im1 = Image.new(mode="RGBA", size=(300, 100)) im2 = Image.new(mode="RGBA", size=(300, 100)) @@ -115,7 +123,7 @@ def helper_assert_pickled_font_images(font1, font2): @skip_unless_feature("freetype2") @pytest.mark.parametrize("protocol", list(range(0, pickle.HIGHEST_PROTOCOL + 1))) -def test_pickle_font_string(protocol): +def test_pickle_font_string(protocol: int) -> None: # Arrange font = ImageFont.truetype(FONT_PATH, FONT_SIZE) @@ -129,7 +137,7 @@ def test_pickle_font_string(protocol): @skip_unless_feature("freetype2") @pytest.mark.parametrize("protocol", list(range(0, pickle.HIGHEST_PROTOCOL + 1))) -def test_pickle_font_file(tmp_path, protocol): +def test_pickle_font_file(tmp_path: Path, protocol: int) -> None: # Arrange font = ImageFont.truetype(FONT_PATH, FONT_SIZE) filename = str(tmp_path / "temp.pkl") diff --git a/Tests/test_psdraw.py b/Tests/test_psdraw.py index 77c7952e9..64dfb2c95 100644 --- a/Tests/test_psdraw.py +++ b/Tests/test_psdraw.py @@ -1,14 +1,16 @@ from __future__ import annotations + import os import sys from io import BytesIO +from pathlib import Path import pytest from PIL import Image, PSDraw -def _create_document(ps): +def _create_document(ps: PSDraw.PSDraw) -> None: title = "hopper" box = (1 * 72, 2 * 72, 7 * 72, 10 * 72) # in points @@ -30,7 +32,7 @@ def _create_document(ps): ps.end_document() -def test_draw_postscript(tmp_path): +def test_draw_postscript(tmp_path: Path) -> None: # Based on Pillow tutorial, but there is no textsize: # https://pillow.readthedocs.io/en/latest/handbook/tutorial.html#drawing-postscript @@ -48,7 +50,7 @@ def test_draw_postscript(tmp_path): @pytest.mark.parametrize("buffer", (True, False)) -def test_stdout(buffer): +def test_stdout(buffer: bool) -> None: # Temporarily redirect stdout old_stdout = sys.stdout diff --git a/Tests/test_pyroma.py b/Tests/test_pyroma.py index 08133b6c3..c2f7fe22e 100644 --- a/Tests/test_pyroma.py +++ b/Tests/test_pyroma.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import __version__ @@ -6,7 +7,7 @@ from PIL import __version__ pyroma = pytest.importorskip("pyroma", reason="Pyroma not installed") -def test_pyroma(): +def test_pyroma() -> None: # Arrange data = pyroma.projectdata.get_data(".") diff --git a/Tests/test_qt_image_qapplication.py b/Tests/test_qt_image_qapplication.py index 49ca01677..3cd323553 100644 --- a/Tests/test_qt_image_qapplication.py +++ b/Tests/test_qt_image_qapplication.py @@ -1,7 +1,10 @@ from __future__ import annotations + +from pathlib import Path + import pytest -from PIL import ImageQt +from PIL import Image, ImageQt from .helper import assert_image_equal_tofile, assert_image_similar, hopper @@ -18,7 +21,7 @@ if ImageQt.qt_is_installed: from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QWidget class Example(QWidget): - def __init__(self): + def __init__(self) -> None: super().__init__() img = hopper().resize((1000, 1000)) @@ -34,14 +37,14 @@ if ImageQt.qt_is_installed: lbl.setPixmap(pixmap1.copy()) -def roundtrip(expected): +def roundtrip(expected: Image.Image) -> None: result = ImageQt.fromqpixmap(ImageQt.toqpixmap(expected)) # Qt saves all pixmaps as rgb assert_image_similar(result, expected.convert("RGB"), 1) @pytest.mark.skipif(not ImageQt.qt_is_installed, reason="Qt bindings are not installed") -def test_sanity(tmp_path): +def test_sanity(tmp_path: Path) -> None: # Segfault test app = QApplication([]) ex = Example() diff --git a/Tests/test_qt_image_toqimage.py b/Tests/test_qt_image_toqimage.py index 396bd9080..6110be707 100644 --- a/Tests/test_qt_image_toqimage.py +++ b/Tests/test_qt_image_toqimage.py @@ -1,4 +1,7 @@ from __future__ import annotations + +from pathlib import Path + import pytest from PIL import ImageQt @@ -14,7 +17,7 @@ if ImageQt.qt_is_installed: @pytest.mark.parametrize("mode", ("RGB", "RGBA", "L", "P", "1")) -def test_sanity(mode, tmp_path): +def test_sanity(mode: str, tmp_path: Path) -> None: src = hopper(mode) data = ImageQt.toqimage(src) diff --git a/Tests/test_sgi_crash.py b/Tests/test_sgi_crash.py index 37d72d451..3ce31cd2d 100644 --- a/Tests/test_sgi_crash.py +++ b/Tests/test_sgi_crash.py @@ -1,4 +1,5 @@ from __future__ import annotations + import pytest from PIL import Image @@ -20,7 +21,7 @@ from PIL import Image "Tests/images/crash-db8bfa78b19721225425530c5946217720d7df4e.sgi", ], ) -def test_crashes(test_file): +def test_crashes(test_file: str) -> None: with open(test_file, "rb") as f: with Image.open(f) as im: with pytest.raises(OSError): diff --git a/Tests/test_shell_injection.py b/Tests/test_shell_injection.py index d93b03904..2a072fd44 100644 --- a/Tests/test_shell_injection.py +++ b/Tests/test_shell_injection.py @@ -1,5 +1,8 @@ from __future__ import annotations + import shutil +from pathlib import Path +from typing import Callable import pytest @@ -15,7 +18,12 @@ test_filenames = ("temp_';", 'temp_";', "temp_'\"|", "temp_'\"||", "temp_'\"&&") @pytest.mark.skipif(is_win32(), reason="Requires Unix or macOS") class TestShellInjection: - def assert_save_filename_check(self, tmp_path, src_img, save_func): + def assert_save_filename_check( + self, + tmp_path: Path, + src_img: Image.Image, + save_func: Callable[[Image.Image, int, str], None], + ) -> None: for filename in test_filenames: dest_file = str(tmp_path / filename) save_func(src_img, 0, dest_file) @@ -24,7 +32,7 @@ class TestShellInjection: im.load() @pytest.mark.skipif(not djpeg_available(), reason="djpeg not available") - def test_load_djpeg_filename(self, tmp_path): + def test_load_djpeg_filename(self, tmp_path: Path) -> None: for filename in test_filenames: src_file = str(tmp_path / filename) shutil.copy(TEST_JPG, src_file) @@ -33,18 +41,18 @@ class TestShellInjection: im.load_djpeg() @pytest.mark.skipif(not cjpeg_available(), reason="cjpeg not available") - def test_save_cjpeg_filename(self, tmp_path): + def test_save_cjpeg_filename(self, tmp_path: Path) -> None: with Image.open(TEST_JPG) as im: self.assert_save_filename_check(tmp_path, im, JpegImagePlugin._save_cjpeg) @pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") - def test_save_netpbm_filename_bmp_mode(self, tmp_path): + def test_save_netpbm_filename_bmp_mode(self, tmp_path: Path) -> None: with Image.open(TEST_GIF) as im: im = im.convert("RGB") self.assert_save_filename_check(tmp_path, im, GifImagePlugin._save_netpbm) @pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") - def test_save_netpbm_filename_l_mode(self, tmp_path): + def test_save_netpbm_filename_l_mode(self, tmp_path: Path) -> None: with Image.open(TEST_GIF) as im: im = im.convert("L") self.assert_save_filename_check(tmp_path, im, GifImagePlugin._save_netpbm) diff --git a/Tests/test_tiff_crashes.py b/Tests/test_tiff_crashes.py index 64e781cba..073e5415c 100644 --- a/Tests/test_tiff_crashes.py +++ b/Tests/test_tiff_crashes.py @@ -42,14 +42,13 @@ from .helper import on_ci @pytest.mark.filterwarnings("ignore:Possibly corrupt EXIF data") @pytest.mark.filterwarnings("ignore:Metadata warning") @pytest.mark.filterwarnings("ignore:Truncated File Read") -def test_tiff_crashes(test_file): +def test_tiff_crashes(test_file: str) -> None: try: with Image.open(test_file) as im: im.load() except FileNotFoundError: - if not on_ci(): - pytest.skip("test image not found") - return - raise + if on_ci(): + raise + pytest.skip("test image not found") except OSError: pass diff --git a/Tests/test_tiff_ifdrational.py b/Tests/test_tiff_ifdrational.py index e7b41fb47..f6adae3e6 100644 --- a/Tests/test_tiff_ifdrational.py +++ b/Tests/test_tiff_ifdrational.py @@ -1,5 +1,7 @@ from __future__ import annotations + from fractions import Fraction +from pathlib import Path from PIL import Image, TiffImagePlugin, features from PIL.TiffImagePlugin import IFDRational @@ -7,14 +9,14 @@ from PIL.TiffImagePlugin import IFDRational from .helper import hopper -def _test_equal(num, denom, target): +def _test_equal(num, denom, target) -> None: t = IFDRational(num, denom) assert target == t assert t == target -def test_sanity(): +def test_sanity() -> None: _test_equal(1, 1, 1) _test_equal(1, 1, Fraction(1, 1)) @@ -30,13 +32,13 @@ def test_sanity(): _test_equal(7, 5, 1.4) -def test_ranges(): +def test_ranges() -> None: for num in range(1, 10): for denom in range(1, 10): assert IFDRational(num, denom) == IFDRational(num, denom) -def test_nonetype(): +def test_nonetype() -> None: # Fails if the _delegate function doesn't return a valid function xres = IFDRational(72) @@ -50,10 +52,10 @@ def test_nonetype(): assert xres and yres -def test_ifd_rational_save(tmp_path): - methods = (True, False) - if not features.check("libtiff"): - methods = (False,) +def test_ifd_rational_save(tmp_path: Path) -> None: + methods = [True] + if features.check("libtiff"): + methods.append(False) for libtiff in methods: TiffImagePlugin.WRITE_LIBTIFF = libtiff diff --git a/Tests/test_uploader.py b/Tests/test_uploader.py index 6b693f7cd..d55ceb4be 100644 --- a/Tests/test_uploader.py +++ b/Tests/test_uploader.py @@ -1,14 +1,15 @@ from __future__ import annotations + from .helper import assert_image_equal, assert_image_similar, hopper -def check_upload_equal(): +def check_upload_equal() -> None: result = hopper("P").convert("RGB") target = hopper("RGB") assert_image_equal(result, target) -def check_upload_similar(): +def check_upload_similar() -> None: result = hopper("P").convert("RGB") target = hopper("RGB") assert_image_similar(result, target, 0) diff --git a/Tests/test_util.py b/Tests/test_util.py index 1457d85f7..197ef79ee 100644 --- a/Tests/test_util.py +++ b/Tests/test_util.py @@ -1,26 +1,16 @@ from __future__ import annotations + +from pathlib import Path, PurePath + import pytest from PIL import _util -def test_is_path(): - # Arrange - fp = "filename.ext" - - # Act - it_is = _util.is_path(fp) - - # Assert - assert it_is - - -def test_path_obj_is_path(): - # Arrange - from pathlib import Path - - test_path = Path("filename.ext") - +@pytest.mark.parametrize( + "test_path", ["filename.ext", Path("filename.ext"), PurePath("filename.ext")] +) +def test_is_path(test_path: str | Path | PurePath) -> None: # Act it_is = _util.is_path(test_path) @@ -28,7 +18,7 @@ def test_path_obj_is_path(): assert it_is -def test_is_not_path(tmp_path): +def test_is_not_path(tmp_path: Path) -> None: # Arrange with (tmp_path / "temp.ext").open("w") as fp: pass @@ -40,7 +30,7 @@ def test_is_not_path(tmp_path): assert not it_is_not -def test_is_directory(): +def test_is_directory() -> None: # Arrange directory = "Tests" @@ -51,7 +41,7 @@ def test_is_directory(): assert it_is -def test_is_not_directory(): +def test_is_not_directory() -> None: # Arrange text = "abc" @@ -62,11 +52,11 @@ def test_is_not_directory(): assert not it_is_not -def test_deferred_error(): +def test_deferred_error() -> None: # Arrange # Act - thing = _util.DeferredError(ValueError("Some error text")) + thing = _util.DeferredError.new(ValueError("Some error text")) # Assert with pytest.raises(ValueError): diff --git a/Tests/test_webp_leaks.py b/Tests/test_webp_leaks.py index 28ebc7d79..626fe427c 100644 --- a/Tests/test_webp_leaks.py +++ b/Tests/test_webp_leaks.py @@ -1,4 +1,5 @@ from __future__ import annotations + from io import BytesIO from PIL import Image @@ -13,11 +14,11 @@ class TestWebPLeaks(PillowLeakTestCase): mem_limit = 3 * 1024 # kb iterations = 100 - def test_leak_load(self): + def test_leak_load(self) -> None: with open(test_file, "rb") as f: im_data = f.read() - def core(): + def core() -> None: with Image.open(BytesIO(im_data)) as im: im.load() diff --git a/depends/download-and-extract.sh b/depends/download-and-extract.sh index a318bfafd..04bfbc755 100755 --- a/depends/download-and-extract.sh +++ b/depends/download-and-extract.sh @@ -5,7 +5,7 @@ archive=$1 url=$2 if [ ! -f $archive.tar.gz ]; then - wget -O $archive.tar.gz $url + wget --no-verbose -O $archive.tar.gz $url fi rmdir $archive diff --git a/depends/install_imagequant.sh b/depends/install_imagequant.sh index b7cebbdbf..973b4374f 100755 --- a/depends/install_imagequant.sh +++ b/depends/install_imagequant.sh @@ -1,15 +1,39 @@ #!/bin/bash # install libimagequant -archive=libimagequant-4.2.2 +archive_name=libimagequant +archive_version=4.3.0 -./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz +archive=$archive_name-$archive_version -pushd $archive/imagequant-sys +if [[ "$GHA_LIBIMAGEQUANT_CACHE_HIT" == "true" ]]; then -cargo install cargo-c -cargo cinstall --prefix=/usr --destdir=. -sudo cp usr/lib/libimagequant.so* /usr/lib/ -sudo cp usr/include/libimagequant.h /usr/include/ + # Copy cached files into place + sudo cp ~/cache-$archive_name/libimagequant.so* /usr/lib/ + sudo cp ~/cache-$archive_name/libimagequant.h /usr/include/ -popd +else + + # Build from source + ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz + + pushd $archive/imagequant-sys + + cargo install cargo-c + cargo cinstall --prefix=/usr --destdir=. + + # Copy into place + sudo cp usr/lib/libimagequant.so* /usr/lib/ + sudo cp usr/include/libimagequant.h /usr/include/ + + if [ -n "$GITHUB_ACTIONS" ]; then + # Copy to cache + rm -rf ~/cache-$archive_name + mkdir ~/cache-$archive_name + cp usr/lib/libimagequant.so* ~/cache-$archive_name/ + cp usr/include/libimagequant.h ~/cache-$archive_name/ + fi + + popd + +fi diff --git a/depends/install_openjpeg.sh b/depends/install_openjpeg.sh index 4f4b81a62..8c2967bc2 100755 --- a/depends/install_openjpeg.sh +++ b/depends/install_openjpeg.sh @@ -1,7 +1,7 @@ #!/bin/bash # install openjpeg -archive=openjpeg-2.5.0 +archive=openjpeg-2.5.2 ./download-and-extract.sh $archive https://raw.githubusercontent.com/python-pillow/pillow-depends/main/$archive.tar.gz diff --git a/docs/COPYING b/docs/COPYING index bc44ba388..73af6d99c 100644 --- a/docs/COPYING +++ b/docs/COPYING @@ -5,7 +5,7 @@ The Python Imaging Library (PIL) is Pillow is the friendly PIL fork. It is - Copyright © 2010-2023 by Jeffrey A. Clark (Alex) and contributors + Copyright © 2010-2024 by Jeffrey A. Clark (Alex) and contributors Like PIL, Pillow is licensed under the open source PIL Software License: diff --git a/docs/PIL.rst b/docs/PIL.rst index fa036b9cc..bdbf1373d 100644 --- a/docs/PIL.rst +++ b/docs/PIL.rst @@ -69,10 +69,10 @@ can be found here. :undoc-members: :show-inheritance: -:mod:`~PIL.ImageTransform` Module ---------------------------------- +:mod:`~PIL.ImageMode` Module +---------------------------- -.. automodule:: PIL.ImageTransform +.. automodule:: PIL.ImageMode :members: :undoc-members: :show-inheritance: diff --git a/docs/about.rst b/docs/about.rst index 872ac0ea6..98cdd8e5a 100644 --- a/docs/about.rst +++ b/docs/about.rst @@ -6,15 +6,14 @@ Goals The fork author's goal is to foster and support active development of PIL through: -- Continuous integration testing via `GitHub Actions`_, `AppVeyor`_ and `Travis CI`_ +- Continuous integration testing via `GitHub Actions`_ and `AppVeyor`_ - Publicized development activity on `GitHub`_ - Regular releases to the `Python Package Index`_ .. _GitHub Actions: https://github.com/python-pillow/Pillow/actions .. _AppVeyor: https://ci.appveyor.com/project/Python-pillow/pillow -.. _Travis CI: https://app.travis-ci.com/github/python-pillow/Pillow .. _GitHub: https://github.com/python-pillow/Pillow -.. _Python Package Index: https://pypi.org/project/Pillow/ +.. _Python Package Index: https://pypi.org/project/pillow/ License ------- @@ -24,7 +23,7 @@ Like PIL, Pillow is `licensed under the open source HPND License `_. In January 2020, `the PyPI moderators exhausted the PEP 541 process for contacting the PIL project owner `_ and the `PIL project on PyPI `_ was transferred to the `Pillow team `_. The Pillow team has no plans to update the PIL project on PyPI. diff --git a/docs/conf.py b/docs/conf.py index a70dece74..97289c91d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -54,7 +54,7 @@ master_doc = "index" # General information about the project. project = "Pillow (PIL Fork)" copyright = ( - "1995-2011 Fredrik Lundh, 2010-2023 Jeffrey A. Clark (Alex) and contributors" + "1995-2011 Fredrik Lundh, 2010-2024 Jeffrey A. Clark (Alex) and contributors" ) author = "Fredrik Lundh, Jeffrey A. Clark (Alex), contributors" @@ -326,7 +326,7 @@ linkcheck_allowed_redirects = { r"https://gitter.im/python-pillow/Pillow?.*": r"https://app.gitter.im/#/room/#python-pillow_Pillow:gitter.im?.*", r"https://pillow.readthedocs.io/?badge=latest": r"https://pillow.readthedocs.io/en/stable/?badge=latest", r"https://pillow.readthedocs.io": r"https://pillow.readthedocs.io/en/stable/", - r"https://tidelift.com/badges/package/pypi/Pillow?.*": r"https://img.shields.io/badge/.*", + r"https://tidelift.com/badges/package/pypi/pillow?.*": r"https://img.shields.io/badge/.*", r"https://zenodo.org/badge/17549/python-pillow/Pillow.svg": r"https://zenodo.org/badge/doi/[\.0-9]+/zenodo.[0-9]+.svg", r"https://zenodo.org/badge/latestdoi/17549/python-pillow/Pillow": r"https://zenodo.org/record/[0-9]+", } diff --git a/docs/deprecations.rst b/docs/deprecations.rst index 75c0b73eb..33bc14187 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -44,6 +44,54 @@ ImageFile.raise_oserror error codes returned by a codec's ``decode()`` method, which ImageFile already does automatically. +IptcImageFile helper functions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 10.2.0 + +The functions ``IptcImageFile.dump`` and ``IptcImageFile.i``, and the constant +``IptcImageFile.PAD`` have been deprecated and will be removed in Pillow +12.0.0 (2025-10-15). These are undocumented helper functions intended +for internal use, so there is no replacement. They can each be replaced +by a single line of code using builtin functions in Python. + +ImageCms constants and versions() function +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 10.3.0 + +A number of constants and a function in :py:mod:`.ImageCms` have been deprecated. +This includes a table of flags based on LittleCMS version 1 which has been +replaced with a new class :py:class:`.ImageCms.Flags` based on LittleCMS 2 flags. + +============================================ ==================================================== +Deprecated Use instead +============================================ ==================================================== +``ImageCms.DESCRIPTION`` No replacement +``ImageCms.VERSION`` ``PIL.__version__`` +``ImageCms.FLAGS["MATRIXINPUT"]`` :py:attr:`.ImageCms.Flags.CLUT_POST_LINEARIZATION` +``ImageCms.FLAGS["MATRIXOUTPUT"]`` :py:attr:`.ImageCms.Flags.FORCE_CLUT` +``ImageCms.FLAGS["MATRIXONLY"]`` No replacement +``ImageCms.FLAGS["NOWHITEONWHITEFIXUP"]`` :py:attr:`.ImageCms.Flags.NOWHITEONWHITEFIXUP` +``ImageCms.FLAGS["NOPRELINEARIZATION"]`` :py:attr:`.ImageCms.Flags.CLUT_PRE_LINEARIZATION` +``ImageCms.FLAGS["GUESSDEVICECLASS"]`` :py:attr:`.ImageCms.Flags.GUESSDEVICECLASS` +``ImageCms.FLAGS["NOTCACHE"]`` :py:attr:`.ImageCms.Flags.NOCACHE` +``ImageCms.FLAGS["NOTPRECALC"]`` :py:attr:`.ImageCms.Flags.NOOPTIMIZE` +``ImageCms.FLAGS["NULLTRANSFORM"]`` :py:attr:`.ImageCms.Flags.NULLTRANSFORM` +``ImageCms.FLAGS["HIGHRESPRECALC"]`` :py:attr:`.ImageCms.Flags.HIGHRESPRECALC` +``ImageCms.FLAGS["LOWRESPRECALC"]`` :py:attr:`.ImageCms.Flags.LOWRESPRECALC` +``ImageCms.FLAGS["GAMUTCHECK"]`` :py:attr:`.ImageCms.Flags.GAMUTCHECK` +``ImageCms.FLAGS["WHITEBLACKCOMPENSATION"]`` :py:attr:`.ImageCms.Flags.BLACKPOINTCOMPENSATION` +``ImageCms.FLAGS["BLACKPOINTCOMPENSATION"]`` :py:attr:`.ImageCms.Flags.BLACKPOINTCOMPENSATION` +``ImageCms.FLAGS["SOFTPROOFING"]`` :py:attr:`.ImageCms.Flags.SOFTPROOFING` +``ImageCms.FLAGS["PRESERVEBLACK"]`` :py:attr:`.ImageCms.Flags.NONEGATIVES` +``ImageCms.FLAGS["NODEFAULTRESOURCEDEF"]`` :py:attr:`.ImageCms.Flags.NODEFAULTRESOURCEDEF` +``ImageCms.FLAGS["GRIDPOINTS"]`` :py:attr:`.ImageCms.Flags.GRIDPOINTS()` +``ImageCms.versions()`` :py:func:`PIL.features.version_module` with + ``feature="littlecms2"``, :py:data:`sys.version` or + :py:data:`sys.version_info`, and ``PIL.__version__`` +============================================ ==================================================== + Removed features ---------------- @@ -107,7 +155,7 @@ Constants .. versionremoved:: 10.0.0 A number of constants have been removed. -Instead, ``enum.IntEnum`` classes have been added. +Instead, :py:class:`enum.IntEnum` classes have been added. .. note:: @@ -184,10 +232,10 @@ Previous code:: im = Image.new("RGB", (100, 100)) draw = ImageDraw.Draw(im) - width, height = draw.textsize("Hello world") + width, height = draw.textsize("Hello world", font) width, height = font.getsize_multiline("Hello\nworld") - width, height = draw.multiline_textsize("Hello\nworld") + width, height = draw.multiline_textsize("Hello\nworld", font) Use instead:: @@ -199,11 +247,43 @@ Use instead:: im = Image.new("RGB", (100, 100)) draw = ImageDraw.Draw(im) - width = draw.textlength("Hello world") + width = draw.textlength("Hello world", font) - left, top, right, bottom = draw.multiline_textbbox((0, 0), "Hello\nworld") + left, top, right, bottom = draw.multiline_textbbox((0, 0), "Hello\nworld", font) width, height = right - left, bottom - top +Previously, the ``size`` methods returned a ``height`` that included the vertical +offset of the text, while the new ``bbox`` methods distinguish this as a ``top`` +offset. + +.. image:: ./example/size_vs_bbox.png + :alt: In bbox methods, top measures the vertical distance above the text, while bottom measures that plus the vertical distance of the text itself. In size methods, height also measures the vertical distance above the text plus the vertical distance of the text itself. + :align: center + +If you are using these methods for aligning text, consider using :ref:`text-anchors` instead +which avoid issues that can occur with non-English text or unusual fonts. +For example, instead of the following code:: + + from PIL import Image, ImageDraw, ImageFont + + font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + im = Image.new("RGB", (100, 100)) + draw = ImageDraw.Draw(im) + width, height = draw.textsize("Hello world", font) + x, y = (100 - width) / 2, (100 - height) / 2 + draw.text((x, y), "Hello world", font=font) + +Use instead:: + + from PIL import Image, ImageDraw, ImageFont + + font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + im = Image.new("RGB", (100, 100)) + draw = ImageDraw.Draw(im) + draw.text((100 / 2, 100 / 2), "Hello world", font=font, anchor="mm") + FreeTypeFont.getmask2 fill parameter ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -327,8 +407,8 @@ ImageCms.CmsProfile attributes .. deprecated:: 3.2.0 .. versionremoved:: 8.0.0 -Some attributes in :py:class:`PIL.ImageCms.CmsProfile` have been removed. From 6.0.0, -they issued a :py:exc:`DeprecationWarning`: +Some attributes in :py:class:`PIL.ImageCms.core.CmsProfile` have been removed. +From 6.0.0, they issued a :py:exc:`DeprecationWarning`: ======================== =================================================== Removed Use instead @@ -456,3 +536,27 @@ PIL.OleFileIO the upstream :pypi:`olefile` Python package, and replaced with an :py:exc:`ImportError` in 5.0.0 (2018-01). The deprecated file has now been removed from Pillow. If needed, install from PyPI (eg. ``python3 -m pip install olefile``). + +import _imaging +~~~~~~~~~~~~~~~ + +.. versionremoved:: 2.1.0 + +Pillow >= 2.1.0 no longer supports ``import _imaging``. +Please use ``from PIL.Image import core as _imaging`` instead. + +Pillow and PIL +~~~~~~~~~~~~~~ + +.. versionremoved:: 1.0.0 + +Pillow and PIL cannot co-exist in the same environment. +Before installing Pillow, please uninstall PIL. + +import Image +~~~~~~~~~~~~ + +.. versionremoved:: 1.0.0 + +Pillow >= 1.0 no longer supports ``import Image``. +Please use ``from PIL import Image`` instead. diff --git a/docs/example/DdsImagePlugin.py b/docs/example/DdsImagePlugin.py index e98bb8680..2a2a0ba29 100644 --- a/docs/example/DdsImagePlugin.py +++ b/docs/example/DdsImagePlugin.py @@ -9,6 +9,7 @@ The contents of this file are hereby released in the public domain (CC0) Full text of the CC0 license: https://creativecommons.org/publicdomain/zero/1.0/ """ + from __future__ import annotations import struct diff --git a/docs/example/size_vs_bbox.png b/docs/example/size_vs_bbox.png new file mode 100644 index 000000000..11a05d2a8 Binary files /dev/null and b/docs/example/size_vs_bbox.png differ diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 9cd65fd48..569ccb769 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -487,6 +487,16 @@ The :py:meth:`~PIL.Image.Image.save` method supports the following options: **exif** If present, the image will be stored with the provided raw EXIF data. +**keep_rgb** + By default, libjpeg converts images with an RGB color space to YCbCr. + If this option is present and true, those images will be stored as RGB + instead. + + When this option is enabled, attempting to chroma-subsample RGB images + with the ``subsampling`` option will raise an :py:exc:`OSError`. + + .. versionadded:: 10.2.0 + **subsampling** If present, sets the subsampling for the encoder. @@ -552,12 +562,13 @@ JPEG 2000 .. versionadded:: 2.4.0 -Pillow reads and writes JPEG 2000 files containing ``L``, ``LA``, ``RGB`` or -``RGBA`` data. It can also read files containing ``YCbCr`` data, which it -converts on read into ``RGB`` or ``RGBA`` depending on whether or not there is -an alpha channel. Pillow supports JPEG 2000 raw codestreams (``.j2k`` files), -as well as boxed JPEG 2000 files (``.j2p`` or ``.jpx`` files). Pillow does -*not* support files whose components have different sampling frequencies. +Pillow reads and writes JPEG 2000 files containing ``L``, ``LA``, ``RGB``, +``RGBA``, or ``YCbCr`` data. When reading, ``YCbCr`` data is converted to +``RGB`` or ``RGBA`` depending on whether or not there is an alpha channel. +Beginning with version 8.3.0, Pillow can read (but not write) ``RGB``, +``RGBA``, and ``YCbCr`` images with subsampled components. Pillow supports +JPEG 2000 raw codestreams (``.j2k`` files), as well as boxed JPEG 2000 files +(``.jp2`` or ``.jpx`` files). When loading, if you set the ``mode`` on the image prior to the :py:meth:`~PIL.Image.Image.load` method being invoked, you can ask Pillow to @@ -685,6 +696,25 @@ PCX Pillow reads and writes PCX files containing ``1``, ``L``, ``P``, or ``RGB`` data. +PFM +^^^ + +.. versionadded:: 10.3.0 + +Pillow reads and writes grayscale (Pf format) Portable FloatMap (PFM) files +containing ``F`` data. + +Color (PF format) PFM files are not supported. + +Opening +~~~~~~~ + +The :py:func:`~PIL.Image.open` function sets the following +:py:attr:`~PIL.Image.Image.info` properties: + +**scale** + The absolute value of the number stored in the *Scale Factor / Endianness* line. + PNG ^^^ diff --git a/docs/handbook/tutorial.rst b/docs/handbook/tutorial.rst index d79f2465f..523e2ad74 100644 --- a/docs/handbook/tutorial.rst +++ b/docs/handbook/tutorial.rst @@ -542,7 +542,7 @@ Reading from URL from PIL import Image from urllib.request import urlopen - url = "https://python-pillow.org/images/pillow-logo.png" + url = "https://python-pillow.org/assets/images/pillow-logo.png" img = Image.open(urlopen(url)) diff --git a/docs/index.rst b/docs/index.rst index 4f577fe9c..1ed9266eb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -41,10 +41,6 @@ Pillow for enterprise is available via the Tidelift Subscription. `Learn more - document.addEventListener('DOMContentLoaded', function() { - activateTab(getOS()); - }); - - -Warnings --------- - -.. warning:: Pillow and PIL cannot co-exist in the same environment. Before installing Pillow, please uninstall PIL. - -.. warning:: Pillow >= 1.0 no longer supports ``import Image``. Please use ``from PIL import Image`` instead. - -.. warning:: Pillow >= 2.1.0 no longer supports ``import _imaging``. Please use ``from PIL.Image import core as _imaging`` instead. - -Python Support --------------- - -Pillow supports these Python versions. - -.. csv-table:: Newer versions - :file: newer-versions.csv - :header-rows: 1 - -.. csv-table:: Older versions - :file: older-versions.csv - :header-rows: 1 - -.. _Linux Installation: -.. _macOS Installation: -.. _Windows Installation: -.. _FreeBSD Installation: - Basic Installation ------------------ -.. note:: +.. Note:: This section has moved to :ref:`basic-installation`. Please update references accordingly. - The following instructions will install Pillow with support for - most common image formats. See :ref:`external-libraries` for a - full list of external libraries supported. +Python Support +-------------- -Install Pillow with :command:`pip`:: - - python3 -m pip install --upgrade pip - python3 -m pip install --upgrade Pillow - -Optionally, install :pypi:`defusedxml` for Pillow to read XMP data, -and :pypi:`olefile` for Pillow to read FPX and MIC images:: - - python3 -m pip install --upgrade defusedxml olefile - - -.. tab:: Linux - - We provide binaries for Linux for each of the supported Python - versions in the manylinux wheel format. These include support for all - optional libraries except libimagequant. Raqm support requires - FriBiDi to be installed separately:: - - python3 -m pip install --upgrade pip - python3 -m pip install --upgrade Pillow - - Most major Linux distributions, including Fedora, Ubuntu and ArchLinux - also include Pillow in packages that previously contained PIL e.g. - ``python-imaging``. Debian splits it into two packages, ``python3-pil`` - and ``python3-pil.imagetk``. - -.. tab:: macOS - - We provide binaries for macOS for each of the supported Python - versions in the wheel format. These include support for all optional - libraries except libimagequant. Raqm support requires - FriBiDi to be installed separately:: - - python3 -m pip install --upgrade pip - python3 -m pip install --upgrade Pillow - - While we provide binaries for both x86-64 and arm64, we do not provide universal2 - binaries. However, it is simple to combine our current binaries to create one:: - - python3 -m pip download --only-binary=:all: --platform macosx_10_10_x86_64 Pillow - python3 -m pip download --only-binary=:all: --platform macosx_11_0_arm64 Pillow - python3 -m pip install delocate - - Then, with the names of the downloaded wheels, use Python to combine them:: - - from delocate.fuse import fuse_wheels - fuse_wheels('Pillow-9.4.0-2-cp39-cp39-macosx_10_10_x86_64.whl', 'Pillow-9.4.0-cp39-cp39-macosx_11_0_arm64.whl', 'Pillow-9.4.0-cp39-cp39-macosx_11_0_universal2.whl') - -.. tab:: Windows - - We provide Pillow binaries for Windows compiled for the matrix of supported - Pythons in the wheel format. These include x86, x86-64 and arm64 versions - (with the exception of Python 3.8 on arm64). These binaries include support - for all optional libraries except libimagequant and libxcb. Raqm support - requires FriBiDi to be installed separately:: - - python3 -m pip install --upgrade pip - python3 -m pip install --upgrade Pillow - - To install Pillow in MSYS2, see `Building on Windows using MSYS2/MinGW`_. - -.. tab:: FreeBSD - - Pillow can be installed on FreeBSD via the official Ports or Packages systems: - - **Ports**:: - - cd /usr/ports/graphics/py-pillow && make install clean - - **Packages**:: - - pkg install py38-pillow - - .. note:: - - The `Pillow FreeBSD port - `_ and packages - are tested by the ports team with all supported FreeBSD versions. - - -.. _Building on Linux: -.. _Building on macOS: -.. _Building on Windows: -.. _Building on Windows using MSYS2/MinGW: -.. _Building on FreeBSD: -.. _Building on Android: - -Building From Source --------------------- - -.. _external-libraries: - -External Libraries -^^^^^^^^^^^^^^^^^^ - -.. note:: - - You **do not need to install all supported external libraries** to - use Pillow's basic features. **Zlib** and **libjpeg** are required - by default. - -.. note:: - - There are Dockerfiles in our `Docker images repo - `_ to install the - dependencies for some operating systems. - -Many of Pillow's features require external libraries: - -* **libjpeg** provides JPEG functionality. - - * Pillow has been tested with libjpeg versions **6b**, **8**, **9-9d** and - libjpeg-turbo version **8**. - * Starting with Pillow 3.0.0, libjpeg is required by default. It can be - disabled with the ``-C jpeg=disable`` flag. - -* **zlib** provides access to compressed PNGs - - * Starting with Pillow 3.0.0, zlib is required by default. It can be - disabled with the ``-C zlib=disable`` flag. - -* **libtiff** provides compressed TIFF functionality - - * Pillow has been tested with libtiff versions **3.x** and **4.0-4.6.0** - -* **libfreetype** provides type related services - -* **littlecms** provides color management - - * Pillow version 2.2.1 and below uses liblcms1, Pillow 2.3.0 and - above uses liblcms2. Tested with **1.19** and **2.7-2.16**. - -* **libwebp** provides the WebP format. - - * Pillow has been tested with version **0.1.3**, which does not read - transparent WebP files. Versions **0.3.0** and above support - transparency. - -* **openjpeg** provides JPEG 2000 functionality. - - * Pillow has been tested with openjpeg **2.0.0**, **2.1.0**, **2.3.1**, - **2.4.0** and **2.5.0**. - * Pillow does **not** support the earlier **1.5** series which ships - with Debian Jessie. - -* **libimagequant** provides improved color quantization - - * Pillow has been tested with libimagequant **2.6-4.2.2** - * Libimagequant is licensed GPLv3, which is more restrictive than - the Pillow license, therefore we will not be distributing binaries - with libimagequant support enabled. - -* **libraqm** provides complex text layout support. - - * libraqm provides bidirectional text support (using FriBiDi), - shaping (using HarfBuzz), and proper script itemization. As a - result, Raqm can support most writing systems covered by Unicode. - * libraqm depends on the following libraries: FreeType, HarfBuzz, - FriBiDi, make sure that you install them before installing libraqm - if not available as package in your system. - * Setting text direction or font features is not supported without libraqm. - * Pillow wheels since version 8.2.0 include a modified version of libraqm that - loads libfribidi at runtime if it is installed. - On Windows this requires compiling FriBiDi and installing ``fribidi.dll`` - into a directory listed in the `Dynamic-link library search order (Microsoft Learn) - `_ - (``fribidi-0.dll`` or ``libfribidi-0.dll`` are also detected). - See `Build Options`_ to see how to build this version. - * Previous versions of Pillow (5.0.0 to 8.1.2) linked libraqm dynamically at runtime. - -* **libxcb** provides X11 screengrab support. - -.. tab:: Linux - - If you didn't build Python from source, make sure you have Python's - development libraries installed. - - In Debian or Ubuntu:: - - sudo apt-get install python3-dev python3-setuptools - - In Fedora, the command is:: - - sudo dnf install python3-devel redhat-rpm-config - - In Alpine, the command is:: - - sudo apk add python3-dev py3-setuptools - - .. Note:: ``redhat-rpm-config`` is required on Fedora 23, but not earlier versions. - - Prerequisites for **Ubuntu 16.04 LTS - 22.04 LTS** are installed with:: - - sudo apt-get install libtiff5-dev libjpeg8-dev libopenjp2-7-dev zlib1g-dev \ - libfreetype6-dev liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev python3-tk \ - libharfbuzz-dev libfribidi-dev libxcb1-dev - - To install libraqm, ``sudo apt-get install meson`` and then see - ``depends/install_raqm.sh``. - - Prerequisites are installed on recent **Red Hat**, **CentOS** or **Fedora** with:: - - sudo dnf install libtiff-devel libjpeg-devel openjpeg2-devel zlib-devel \ - freetype-devel lcms2-devel libwebp-devel tcl-devel tk-devel \ - harfbuzz-devel fribidi-devel libraqm-devel libimagequant-devel libxcb-devel - - Note that the package manager may be yum or DNF, depending on the - exact distribution. - - Prerequisites are installed for **Alpine** with:: - - sudo apk add tiff-dev jpeg-dev openjpeg-dev zlib-dev freetype-dev lcms2-dev \ - libwebp-dev tcl-dev tk-dev harfbuzz-dev fribidi-dev libimagequant-dev \ - libxcb-dev libpng-dev - - See also the ``Dockerfile``\s in the Test Infrastructure repo - (https://github.com/python-pillow/docker-images) for a known working - install process for other tested distros. - -.. tab:: macOS - - The Xcode command line tools are required to compile portions of - Pillow. The tools are installed by running ``xcode-select --install`` - from the command line. The command line tools are required even if you - have the full Xcode package installed. It may be necessary to run - ``sudo xcodebuild -license`` to accept the license prior to using the - tools. - - The easiest way to install external libraries is via `Homebrew - `_. After you install Homebrew, run:: - - brew install libjpeg libtiff little-cms2 openjpeg webp - - To install libraqm on macOS use Homebrew to install its dependencies:: - - brew install freetype harfbuzz fribidi - - Then see ``depends/install_raqm_cmake.sh`` to install libraqm. - -.. tab:: Windows - - We recommend you use prebuilt wheels from PyPI. - If you wish to compile Pillow manually, you can use the build scripts - in the ``winbuild`` directory used for CI testing and development. - These scripts require Visual Studio 2017 or newer and NASM. - - The scripts also install Pillow from the local copy of the source code, so the - `Installing`_ instructions will not be necessary afterwards. - -.. tab:: Windows using MSYS2/MinGW - - To build Pillow using MSYS2, make sure you run the **MSYS2 MinGW 32-bit** or - **MSYS2 MinGW 64-bit** console, *not* **MSYS2** directly. - - The following instructions target the 64-bit build, for 32-bit - replace all occurrences of ``mingw-w64-x86_64-`` with ``mingw-w64-i686-``. - - Make sure you have Python and GCC installed:: - - pacman -S \ - mingw-w64-x86_64-gcc \ - mingw-w64-x86_64-python3 \ - mingw-w64-x86_64-python3-pip \ - mingw-w64-x86_64-python3-setuptools - - Prerequisites are installed on **MSYS2 MinGW 64-bit** with:: - - pacman -S \ - mingw-w64-x86_64-libjpeg-turbo \ - mingw-w64-x86_64-zlib \ - mingw-w64-x86_64-libtiff \ - mingw-w64-x86_64-freetype \ - mingw-w64-x86_64-lcms2 \ - mingw-w64-x86_64-libwebp \ - mingw-w64-x86_64-openjpeg2 \ - mingw-w64-x86_64-libimagequant \ - mingw-w64-x86_64-libraqm - - https://www.msys2.org/docs/python/ states that setuptools >= 60 does not work with - MSYS2. To workaround this, before installing Pillow you must run:: - - export SETUPTOOLS_USE_DISTUTILS=stdlib - -.. tab:: FreeBSD - - .. Note:: Only FreeBSD 10 and 11 tested - - Make sure you have Python's development libraries installed:: - - sudo pkg install python3 - - Prerequisites are installed on **FreeBSD 10 or 11** with:: - - sudo pkg install jpeg-turbo tiff webp lcms2 freetype2 openjpeg harfbuzz fribidi libxcb - - Then see ``depends/install_raqm_cmake.sh`` to install libraqm. - -.. tab:: Android - - Basic Android support has been added for compilation within the Termux - environment. The dependencies can be installed by:: - - pkg install -y python ndk-sysroot clang make \ - libjpeg-turbo - - This has been tested within the Termux app on ChromeOS, on x86. - -Installing -^^^^^^^^^^ - -Once you have installed the prerequisites, to install Pillow from the source -code on PyPI, run:: - - python3 -m pip install --upgrade pip - python3 -m pip install --upgrade Pillow --no-binary :all: - -If the prerequisites are installed in the standard library locations -for your machine (e.g. :file:`/usr` or :file:`/usr/local`), no -additional configuration should be required. If they are installed in -a non-standard location, you may need to configure setuptools to use -those locations by editing :file:`setup.py` or -:file:`pyproject.toml`, or by adding environment variables on the command -line:: - - CFLAGS="-I/usr/pkg/include" python3 -m pip install --upgrade Pillow --no-binary :all: - -If Pillow has been previously built without the required -prerequisites, it may be necessary to manually clear the pip cache or -build without cache using the ``--no-cache-dir`` option to force a -build with newly installed external libraries. - -If you would like to install from a local copy of the source code instead, you -can clone from GitHub with ``git clone https://github.com/python-pillow/Pillow`` -or download and extract the `compressed archive from PyPI`_. - -After navigating to the Pillow directory, run:: - - python3 -m pip install --upgrade pip - python3 -m pip install . - -.. _compressed archive from PyPI: https://pypi.org/project/Pillow/#files - -Build Options -""""""""""""" - -* Environment variable: ``MAX_CONCURRENCY=n``. Pillow can use - multiprocessing to build the extension. Setting ``MAX_CONCURRENCY`` - sets the number of CPUs to use, or can disable parallel building by - using a setting of 1. By default, it uses 4 CPUs, or if 4 are not - available, as many as are present. - -* Config settings: ``-C zlib=disable``, ``-C jpeg=disable``, - ``-C tiff=disable``, ``-C freetype=disable``, ``-C raqm=disable``, - ``-C lcms=disable``, ``-C webp=disable``, ``-C webpmux=disable``, - ``-C jpeg2000=disable``, ``-C imagequant=disable``, ``-C xcb=disable``. - Disable building the corresponding feature even if the development - libraries are present on the building machine. - -* Config settings: ``-C zlib=enable``, ``-C jpeg=enable``, - ``-C tiff=enable``, ``-C freetype=enable``, ``-C raqm=enable``, - ``-C lcms=enable``, ``-C webp=enable``, ``-C webpmux=enable``, - ``-C jpeg2000=enable``, ``-C imagequant=enable``, ``-C xcb=enable``. - Require that the corresponding feature is built. The build will raise - an exception if the libraries are not found. Webpmux (WebP metadata) - relies on WebP support. Tcl and Tk also must be used together. - -* Config settings: ``-C raqm=vendor``, ``-C fribidi=vendor``. - These flags are used to compile a modified version of libraqm and - a shim that dynamically loads libfribidi at runtime. These are - used to compile the standard Pillow wheels. Compiling libraqm requires - a C99-compliant compiler. - -* Build flag: ``-C platform-guessing=disable``. Skips all of the - platform dependent guessing of include and library directories for - automated build systems that configure the proper paths in the - environment variables (e.g. Buildroot). - -* Build flag: ``-C debug=true``. Adds a debugging flag to the include and - library search process to dump all paths searched for and found to - stdout. - - -Sample usage:: - - python3 -m pip install --upgrade Pillow -C [feature]=enable +.. Note:: This section has moved to :ref:`python-support`. Please update references accordingly. Platform Support ---------------- -Current platform support for Pillow. Binary distributions are -contributed for each release on a volunteer basis, but the source -should compile and run everywhere platform support is listed. In -general, we aim to support all current versions of Linux, macOS, and -Windows. +.. Note:: This section has moved to :ref:`platform-support`. Please update references accordingly. -Continuous Integration Targets -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Building From Source +-------------------- -These platforms are built and tested for every change. - -+----------------------------------+----------------------------+---------------------+ -| Operating system | Tested Python versions | Tested architecture | -+==================================+============================+=====================+ -| Alpine | 3.9 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| Amazon Linux 2 | 3.9 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| Amazon Linux 2023 | 3.9 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| Arch | 3.9 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| CentOS 7 | 3.9 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| CentOS Stream 8 | 3.9 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| CentOS Stream 9 | 3.9 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| Debian 11 Bullseye | 3.9 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| Debian 12 Bookworm | 3.11 | x86, x86-64 | -+----------------------------------+----------------------------+---------------------+ -| Fedora 38 | 3.11 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| Fedora 39 | 3.12 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| Gentoo | 3.9 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| macOS 12 Monterey | 3.8, 3.9, 3.10, 3.11, | x86-64 | -| | 3.12, PyPy3 | | -+----------------------------------+----------------------------+---------------------+ -| Ubuntu Linux 20.04 LTS (Focal) | 3.8 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| Ubuntu Linux 22.04 LTS (Jammy) | 3.8, 3.9, 3.10, 3.11, | x86-64 | -| | 3.12, PyPy3 | | -| +----------------------------+---------------------+ -| | 3.10 | arm64v8, ppc64le, | -| | | s390x | -+----------------------------------+----------------------------+---------------------+ -| Windows Server 2016 | 3.8 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| Windows Server 2022 | 3.8, 3.9, 3.10, 3.11, | x86-64 | -| | 3.12, PyPy3 | | -| +----------------------------+---------------------+ -| | 3.12 | x86 | -| +----------------------------+---------------------+ -| | 3.9 (MinGW) | x86-64 | -| +----------------------------+---------------------+ -| | 3.8, 3.9 (Cygwin) | x86-64 | -+----------------------------------+----------------------------+---------------------+ - - -Other Platforms -^^^^^^^^^^^^^^^ - -These platforms have been reported to work at the versions mentioned. - -.. note:: - - Contributors please test Pillow on your platform then update this - document and send a pull request. - -+----------------------------------+----------------------------+------------------+--------------+ -| Operating system | | Tested Python | | Latest tested | | Tested | -| | | versions | | Pillow version | | processors | -+==================================+============================+==================+==============+ -| macOS 14 Sonoma | 3.8, 3.9, 3.10, 3.11, 3.12 | 10.1.0 |arm | -+----------------------------------+----------------------------+------------------+--------------+ -| macOS 13 Ventura | 3.8, 3.9, 3.10, 3.11 | 10.0.1 |arm | -| +----------------------------+------------------+ | -| | 3.7 | 9.5.0 | | -+----------------------------------+----------------------------+------------------+--------------+ -| macOS 12 Monterey | 3.7, 3.8, 3.9, 3.10, 3.11 | 9.3.0 |arm | -+----------------------------------+----------------------------+------------------+--------------+ -| macOS 11 Big Sur | 3.7, 3.8, 3.9, 3.10 | 8.4.0 |arm | -| +----------------------------+------------------+--------------+ -| | 3.7, 3.8, 3.9, 3.10, 3.11 | 9.4.0 |x86-64 | -| +----------------------------+------------------+ | -| | 3.6 | 8.4.0 | | -+----------------------------------+----------------------------+------------------+--------------+ -| macOS 10.15 Catalina | 3.6, 3.7, 3.8, 3.9 | 8.3.2 |x86-64 | -| +----------------------------+------------------+ | -| | 3.5 | 7.2.0 | | -+----------------------------------+----------------------------+------------------+--------------+ -| macOS 10.14 Mojave | 3.5, 3.6, 3.7, 3.8 | 7.2.0 |x86-64 | -| +----------------------------+------------------+ | -| | 2.7 | 6.0.0 | | -| +----------------------------+------------------+ | -| | 3.4 | 5.4.1 | | -+----------------------------------+----------------------------+------------------+--------------+ -| macOS 10.13 High Sierra | 2.7, 3.4, 3.5, 3.6 | 4.2.1 |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| macOS 10.12 Sierra | 2.7, 3.4, 3.5, 3.6 | 4.1.1 |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Mac OS X 10.11 El Capitan | 2.7, 3.4, 3.5, 3.6, 3.7 | 5.4.1 |x86-64 | -| +----------------------------+------------------+ | -| | 3.3 | 4.1.0 | | -+----------------------------------+----------------------------+------------------+--------------+ -| Mac OS X 10.9 Mavericks | 2.7, 3.2, 3.3, 3.4 | 3.0.0 |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Mac OS X 10.8 Mountain Lion | 2.6, 2.7, 3.2, 3.3 | |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Redhat Linux 6 | 2.6 | |x86 | -+----------------------------------+----------------------------+------------------+--------------+ -| CentOS 6.3 | 2.7, 3.3 | |x86 | -+----------------------------------+----------------------------+------------------+--------------+ -| CentOS 8 | 3.9 | 9.0.0 |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Fedora 23 | 2.7, 3.4 | 3.1.0 |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Ubuntu Linux 12.04 LTS (Precise) | | 2.6, 3.2, 3.3, 3.4, 3.5 | 3.4.1 |x86,x86-64 | -| | | PyPy5.3.1, PyPy3 v2.4.0 | | | -| +----------------------------+------------------+--------------+ -| | 2.7 | 4.3.0 |x86-64 | -| +----------------------------+------------------+--------------+ -| | 2.7, 3.2 | 3.4.1 |ppc | -+----------------------------------+----------------------------+------------------+--------------+ -| Ubuntu Linux 10.04 LTS (Lucid) | 2.6 | 2.3.0 |x86,x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Debian 8.2 Jessie | 2.7, 3.4 | 3.1.0 |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Raspbian Jessie | 2.7, 3.4 | 3.1.0 |arm | -+----------------------------------+----------------------------+------------------+--------------+ -| Raspbian Stretch | 2.7, 3.5 | 4.0.0 |arm | -+----------------------------------+----------------------------+------------------+--------------+ -| Raspberry Pi OS | 3.6, 3.7, 3.8, 3.9 | 8.2.0 |arm | -| +----------------------------+------------------+ | -| | 2.7 | 6.2.2 | | -+----------------------------------+----------------------------+------------------+--------------+ -| Gentoo Linux | 2.7, 3.2 | 2.1.0 |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| FreeBSD 11.1 | 2.7, 3.4, 3.5, 3.6 | 4.3.0 |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| FreeBSD 10.3 | 2.7, 3.4, 3.5 | 4.2.0 |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| FreeBSD 10.2 | 2.7, 3.4 | 3.1.0 |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Windows 11 | 3.9, 3.10, 3.11, 3.12 | 10.1.0 |arm64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Windows 11 Pro | 3.11, 3.12 | 10.1.0 |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Windows 10 | 3.7 | 7.1.0 |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Windows 10/Cygwin 3.3 | 3.6, 3.7, 3.8, 3.9 | 8.4.0 |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Windows 8.1 Pro | 2.6, 2.7, 3.2, 3.3, 3.4 | 2.4.0 |x86,x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Windows 8 Pro | 2.6, 2.7, 3.2, 3.3, 3.4a3 | 2.2.0 |x86,x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Windows 7 Professional | 3.7 | 7.0.0 |x86,x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ -| Windows Server 2008 R2 Enterprise| 3.3 | |x86-64 | -+----------------------------------+----------------------------+------------------+--------------+ +.. Note:: This section has moved to :ref:`building-from-source`. Please update references accordingly. Old Versions ------------ -You can download old distributions from the `release history at PyPI -`_ and by direct URL access -eg. https://pypi.org/project/Pillow/1.0/. +.. Note:: This section has moved to :ref:`old-versions`. Please update references accordingly. diff --git a/docs/installation/basic-installation.rst b/docs/installation/basic-installation.rst new file mode 100644 index 000000000..01981aa4f --- /dev/null +++ b/docs/installation/basic-installation.rst @@ -0,0 +1,97 @@ +.. raw:: html + + + +.. _basic-installation: + +Basic Installation +================== + +.. note:: + + The following instructions will install Pillow with support for + most common image formats. See :ref:`external-libraries` for a + full list of external libraries supported. + +Install Pillow with :command:`pip`:: + + python3 -m pip install --upgrade pip + python3 -m pip install --upgrade Pillow + +Optionally, install :pypi:`defusedxml` for Pillow to read XMP data, +and :pypi:`olefile` for Pillow to read FPX and MIC images:: + + python3 -m pip install --upgrade defusedxml olefile + + +.. tab:: Linux + + We provide binaries for Linux for each of the supported Python + versions in the manylinux wheel format. These include support for all + optional libraries except libimagequant. Raqm support requires + FriBiDi to be installed separately:: + + python3 -m pip install --upgrade pip + python3 -m pip install --upgrade Pillow + + Most major Linux distributions, including Fedora, Ubuntu and ArchLinux + also include Pillow in packages that previously contained PIL e.g. + ``python-imaging``. Debian splits it into two packages, ``python3-pil`` + and ``python3-pil.imagetk``. + +.. tab:: macOS + + We provide binaries for macOS for each of the supported Python + versions in the wheel format. These include support for all optional + libraries except libimagequant. Raqm support requires + FriBiDi to be installed separately:: + + python3 -m pip install --upgrade pip + python3 -m pip install --upgrade Pillow + + While we provide binaries for both x86-64 and arm64, we do not provide universal2 + binaries. However, it is simple to combine our current binaries to create one:: + + python3 -m pip download --only-binary=:all: --platform macosx_10_10_x86_64 Pillow + python3 -m pip download --only-binary=:all: --platform macosx_11_0_arm64 Pillow + python3 -m pip install delocate + + Then, with the names of the downloaded wheels, use Python to combine them:: + + from delocate.fuse import fuse_wheels + fuse_wheels('Pillow-9.4.0-2-cp39-cp39-macosx_10_10_x86_64.whl', 'Pillow-9.4.0-cp39-cp39-macosx_11_0_arm64.whl', 'Pillow-9.4.0-cp39-cp39-macosx_11_0_universal2.whl') + +.. tab:: Windows + + We provide Pillow binaries for Windows compiled for the matrix of supported + Pythons in the wheel format. These include x86, x86-64 and arm64 versions + (with the exception of Python 3.8 on arm64). These binaries include support + for all optional libraries except libimagequant and libxcb. Raqm support + requires FriBiDi to be installed separately:: + + python3 -m pip install --upgrade pip + python3 -m pip install --upgrade Pillow + + To install Pillow in MSYS2, see :ref:`building-from-source`. + +.. tab:: FreeBSD + + Pillow can be installed on FreeBSD via the official Ports or Packages systems: + + **Ports**:: + + cd /usr/ports/graphics/py-pillow && make install clean + + **Packages**:: + + pkg install py38-pillow + + .. note:: + + The `Pillow FreeBSD port + `_ and packages + are tested by the ports team with all supported FreeBSD versions. diff --git a/docs/installation/building-from-source.rst b/docs/installation/building-from-source.rst new file mode 100644 index 000000000..97b1fdde3 --- /dev/null +++ b/docs/installation/building-from-source.rst @@ -0,0 +1,317 @@ +.. raw:: html + + + +.. _building-from-source: + +Building From Source +==================== + +.. _external-libraries: + +External Libraries +------------------ + +.. note:: + + You **do not need to install all supported external libraries** to + use Pillow's basic features. **Zlib** and **libjpeg** are required + by default. + +.. note:: + + There are Dockerfiles in our `Docker images repo + `_ to install the + dependencies for some operating systems. + +Many of Pillow's features require external libraries: + +* **libjpeg** provides JPEG functionality. + + * Pillow has been tested with libjpeg versions **6b**, **8**, **9-9d** and + libjpeg-turbo version **8**. + * Starting with Pillow 3.0.0, libjpeg is required by default. It can be + disabled with the ``-C jpeg=disable`` flag. + +* **zlib** provides access to compressed PNGs + + * Starting with Pillow 3.0.0, zlib is required by default. It can be + disabled with the ``-C zlib=disable`` flag. + +* **libtiff** provides compressed TIFF functionality + + * Pillow has been tested with libtiff versions **3.x** and **4.0-4.6.0** + +* **libfreetype** provides type related services + +* **littlecms** provides color management + + * Pillow version 2.2.1 and below uses liblcms1, Pillow 2.3.0 and + above uses liblcms2. Tested with **1.19** and **2.7-2.16**. + +* **libwebp** provides the WebP format. + + * Pillow has been tested with version **0.1.3**, which does not read + transparent WebP files. Versions **0.3.0** and above support + transparency. + +* **openjpeg** provides JPEG 2000 functionality. + + * Pillow has been tested with openjpeg **2.0.0**, **2.1.0**, **2.3.1**, + **2.4.0**, **2.5.0** and **2.5.2**. + * Pillow does **not** support the earlier **1.5** series which ships + with Debian Jessie. + +* **libimagequant** provides improved color quantization + + * Pillow has been tested with libimagequant **2.6-4.3** + * Libimagequant is licensed GPLv3, which is more restrictive than + the Pillow license, therefore we will not be distributing binaries + with libimagequant support enabled. + +* **libraqm** provides complex text layout support. + + * libraqm provides bidirectional text support (using FriBiDi), + shaping (using HarfBuzz), and proper script itemization. As a + result, Raqm can support most writing systems covered by Unicode. + * libraqm depends on the following libraries: FreeType, HarfBuzz, + FriBiDi, make sure that you install them before installing libraqm + if not available as package in your system. + * Setting text direction or font features is not supported without libraqm. + * Pillow wheels since version 8.2.0 include a modified version of libraqm that + loads libfribidi at runtime if it is installed. + On Windows this requires compiling FriBiDi and installing ``fribidi.dll`` + into a directory listed in the `Dynamic-link library search order (Microsoft Learn) + `_ + (``fribidi-0.dll`` or ``libfribidi-0.dll`` are also detected). + See `Build Options`_ to see how to build this version. + * Previous versions of Pillow (5.0.0 to 8.1.2) linked libraqm dynamically at runtime. + +* **libxcb** provides X11 screengrab support. + +.. tab:: Linux + + If you didn't build Python from source, make sure you have Python's + development libraries installed. + + In Debian or Ubuntu:: + + sudo apt-get install python3-dev python3-setuptools + + In Fedora, the command is:: + + sudo dnf install python3-devel redhat-rpm-config + + In Alpine, the command is:: + + sudo apk add python3-dev py3-setuptools + + .. Note:: ``redhat-rpm-config`` is required on Fedora 23, but not earlier versions. + + Prerequisites for **Ubuntu 16.04 LTS - 22.04 LTS** are installed with:: + + sudo apt-get install libtiff5-dev libjpeg8-dev libopenjp2-7-dev zlib1g-dev \ + libfreetype6-dev liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev python3-tk \ + libharfbuzz-dev libfribidi-dev libxcb1-dev + + To install libraqm, ``sudo apt-get install meson`` and then see + ``depends/install_raqm.sh``. + + Prerequisites are installed on recent **Red Hat**, **CentOS** or **Fedora** with:: + + sudo dnf install libtiff-devel libjpeg-devel openjpeg2-devel zlib-devel \ + freetype-devel lcms2-devel libwebp-devel tcl-devel tk-devel \ + harfbuzz-devel fribidi-devel libraqm-devel libimagequant-devel libxcb-devel + + Note that the package manager may be yum or DNF, depending on the + exact distribution. + + Prerequisites are installed for **Alpine** with:: + + sudo apk add tiff-dev jpeg-dev openjpeg-dev zlib-dev freetype-dev lcms2-dev \ + libwebp-dev tcl-dev tk-dev harfbuzz-dev fribidi-dev libimagequant-dev \ + libxcb-dev libpng-dev + + See also the ``Dockerfile``\s in the Test Infrastructure repo + (https://github.com/python-pillow/docker-images) for a known working + install process for other tested distros. + +.. tab:: macOS + + The Xcode command line tools are required to compile portions of + Pillow. The tools are installed by running ``xcode-select --install`` + from the command line. The command line tools are required even if you + have the full Xcode package installed. It may be necessary to run + ``sudo xcodebuild -license`` to accept the license prior to using the + tools. + + The easiest way to install external libraries is via `Homebrew + `_. After you install Homebrew, run:: + + brew install libjpeg libtiff little-cms2 openjpeg webp + + To install libraqm on macOS use Homebrew to install its dependencies:: + + brew install freetype harfbuzz fribidi + + Then see ``depends/install_raqm_cmake.sh`` to install libraqm. + +.. tab:: Windows + + We recommend you use prebuilt wheels from PyPI. + If you wish to compile Pillow manually, you can use the build scripts + in the ``winbuild`` directory used for CI testing and development. + These scripts require Visual Studio 2017 or newer and NASM. + + The scripts also install Pillow from the local copy of the source code, so the + `Installing`_ instructions will not be necessary afterwards. + +.. tab:: Windows using MSYS2/MinGW + + To build Pillow using MSYS2, make sure you run the **MSYS2 MinGW 32-bit** or + **MSYS2 MinGW 64-bit** console, *not* **MSYS2** directly. + + The following instructions target the 64-bit build, for 32-bit + replace all occurrences of ``mingw-w64-x86_64-`` with ``mingw-w64-i686-``. + + Make sure you have Python and GCC installed:: + + pacman -S \ + mingw-w64-x86_64-gcc \ + mingw-w64-x86_64-python3 \ + mingw-w64-x86_64-python3-pip \ + mingw-w64-x86_64-python3-setuptools + + Prerequisites are installed on **MSYS2 MinGW 64-bit** with:: + + pacman -S \ + mingw-w64-x86_64-libjpeg-turbo \ + mingw-w64-x86_64-zlib \ + mingw-w64-x86_64-libtiff \ + mingw-w64-x86_64-freetype \ + mingw-w64-x86_64-lcms2 \ + mingw-w64-x86_64-libwebp \ + mingw-w64-x86_64-openjpeg2 \ + mingw-w64-x86_64-libimagequant \ + mingw-w64-x86_64-libraqm + + https://www.msys2.org/docs/python/ states that setuptools >= 60 does not work with + MSYS2. To workaround this, before installing Pillow you must run:: + + export SETUPTOOLS_USE_DISTUTILS=stdlib + +.. tab:: FreeBSD + + .. Note:: Only FreeBSD 10 and 11 tested + + Make sure you have Python's development libraries installed:: + + sudo pkg install python3 + + Prerequisites are installed on **FreeBSD 10 or 11** with:: + + sudo pkg install jpeg-turbo tiff webp lcms2 freetype2 openjpeg harfbuzz fribidi libxcb + + Then see ``depends/install_raqm_cmake.sh`` to install libraqm. + +.. tab:: Android + + Basic Android support has been added for compilation within the Termux + environment. The dependencies can be installed by:: + + pkg install -y python ndk-sysroot clang make \ + libjpeg-turbo + + This has been tested within the Termux app on ChromeOS, on x86. + +Installing +---------- + +Once you have installed the prerequisites, to install Pillow from the source +code on PyPI, run:: + + python3 -m pip install --upgrade pip + python3 -m pip install --upgrade Pillow --no-binary :all: + +If the prerequisites are installed in the standard library locations +for your machine (e.g. :file:`/usr` or :file:`/usr/local`), no +additional configuration should be required. If they are installed in +a non-standard location, you may need to configure setuptools to use +those locations by editing :file:`setup.py` or +:file:`pyproject.toml`, or by adding environment variables on the command +line:: + + CFLAGS="-I/usr/pkg/include" python3 -m pip install --upgrade Pillow --no-binary :all: + +If Pillow has been previously built without the required +prerequisites, it may be necessary to manually clear the pip cache or +build without cache using the ``--no-cache-dir`` option to force a +build with newly installed external libraries. + +If you would like to install from a local copy of the source code instead, you +can clone from GitHub with ``git clone https://github.com/python-pillow/Pillow`` +or download and extract the `compressed archive from PyPI`_. + +After navigating to the Pillow directory, run:: + + python3 -m pip install --upgrade pip + python3 -m pip install . + +.. _compressed archive from PyPI: https://pypi.org/project/pillow/#files + +Build Options +^^^^^^^^^^^^^ + +* Environment variable: ``MAX_CONCURRENCY=n``. Pillow can use + multiprocessing to build the extension. Setting ``MAX_CONCURRENCY`` + sets the number of CPUs to use, or can disable parallel building by + using a setting of 1. By default, it uses 4 CPUs, or if 4 are not + available, as many as are present. + +* Config settings: ``-C zlib=disable``, ``-C jpeg=disable``, + ``-C tiff=disable``, ``-C freetype=disable``, ``-C raqm=disable``, + ``-C lcms=disable``, ``-C webp=disable``, ``-C webpmux=disable``, + ``-C jpeg2000=disable``, ``-C imagequant=disable``, ``-C xcb=disable``. + Disable building the corresponding feature even if the development + libraries are present on the building machine. + +* Config settings: ``-C zlib=enable``, ``-C jpeg=enable``, + ``-C tiff=enable``, ``-C freetype=enable``, ``-C raqm=enable``, + ``-C lcms=enable``, ``-C webp=enable``, ``-C webpmux=enable``, + ``-C jpeg2000=enable``, ``-C imagequant=enable``, ``-C xcb=enable``. + Require that the corresponding feature is built. The build will raise + an exception if the libraries are not found. Webpmux (WebP metadata) + relies on WebP support. Tcl and Tk also must be used together. + +* Config settings: ``-C raqm=vendor``, ``-C fribidi=vendor``. + These flags are used to compile a modified version of libraqm and + a shim that dynamically loads libfribidi at runtime. These are + used to compile the standard Pillow wheels. Compiling libraqm requires + a C99-compliant compiler. + +* Build flag: ``-C platform-guessing=disable``. Skips all of the + platform dependent guessing of include and library directories for + automated build systems that configure the proper paths in the + environment variables (e.g. Buildroot). + +* Build flag: ``-C debug=true``. Adds a debugging flag to the include and + library search process to dump all paths searched for and found to + stdout. + + +Sample usage:: + + python3 -m pip install --upgrade Pillow -C [feature]=enable + +.. _old-versions: + +Old Versions +============ + +You can download old distributions from the `release history at PyPI +`_ and by direct URL access +eg. https://pypi.org/project/pillow/1.0/. diff --git a/docs/installation/index.rst b/docs/installation/index.rst new file mode 100644 index 000000000..a94204b6b --- /dev/null +++ b/docs/installation/index.rst @@ -0,0 +1,10 @@ +Installation +============ + +.. toctree:: + :maxdepth: 2 + + basic-installation + python-support + platform-support + building-from-source diff --git a/docs/newer-versions.csv b/docs/installation/newer-versions.csv similarity index 100% rename from docs/newer-versions.csv rename to docs/installation/newer-versions.csv diff --git a/docs/older-versions.csv b/docs/installation/older-versions.csv similarity index 100% rename from docs/older-versions.csv rename to docs/installation/older-versions.csv diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst new file mode 100644 index 000000000..59fc312ab --- /dev/null +++ b/docs/installation/platform-support.rst @@ -0,0 +1,170 @@ +.. _platform-support: + +Platform Support +================ + +Current platform support for Pillow. Binary distributions are +contributed for each release on a volunteer basis, but the source +should compile and run everywhere platform support is listed. In +general, we aim to support all current versions of Linux, macOS, and +Windows. + +Continuous Integration Targets +------------------------------ + +These platforms are built and tested for every change. + ++----------------------------------+----------------------------+---------------------+ +| Operating system | Tested Python versions | Tested architecture | ++==================================+============================+=====================+ +| Alpine | 3.9 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Amazon Linux 2 | 3.9 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Amazon Linux 2023 | 3.9 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Arch | 3.9 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| CentOS 7 | 3.9 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| CentOS Stream 8 | 3.9 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| CentOS Stream 9 | 3.9 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Debian 11 Bullseye | 3.9 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Debian 12 Bookworm | 3.11 | x86, x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Fedora 38 | 3.11 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Fedora 39 | 3.12 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Gentoo | 3.9 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| macOS 12 Monterey | 3.8, 3.9 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| macOS 14 Sonoma | 3.10, 3.11, 3.12, 3.13, | arm64 | +| | PyPy3 | | ++----------------------------------+----------------------------+---------------------+ +| Ubuntu Linux 20.04 LTS (Focal) | 3.8 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Ubuntu Linux 22.04 LTS (Jammy) | 3.8, 3.9, 3.10, 3.11, | x86-64 | +| | 3.12, 3.13, PyPy3 | | +| +----------------------------+---------------------+ +| | 3.10 | arm64v8, ppc64le, | +| | | s390x | ++----------------------------------+----------------------------+---------------------+ +| Windows Server 2016 | 3.8 | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Windows Server 2022 | 3.8, 3.9, 3.10, 3.11, | x86-64 | +| | 3.12, 3.13, PyPy3 | | +| +----------------------------+---------------------+ +| | 3.12 | x86 | +| +----------------------------+---------------------+ +| | 3.9 (MinGW) | x86-64 | +| +----------------------------+---------------------+ +| | 3.8, 3.9 (Cygwin) | x86-64 | ++----------------------------------+----------------------------+---------------------+ + + +Other Platforms +--------------- + +These platforms have been reported to work at the versions mentioned. + +.. note:: + + Contributors please test Pillow on your platform then update this + document and send a pull request. + ++----------------------------------+----------------------------+------------------+--------------+ +| Operating system | | Tested Python | | Latest tested | | Tested | +| | | versions | | Pillow version | | processors | ++==================================+============================+==================+==============+ +| macOS 14 Sonoma | 3.8, 3.9, 3.10, 3.11, 3.12 | 10.2.0 |arm | ++----------------------------------+----------------------------+------------------+--------------+ +| macOS 13 Ventura | 3.8, 3.9, 3.10, 3.11 | 10.0.1 |arm | +| +----------------------------+------------------+ | +| | 3.7 | 9.5.0 | | ++----------------------------------+----------------------------+------------------+--------------+ +| macOS 12 Monterey | 3.7, 3.8, 3.9, 3.10, 3.11 | 9.3.0 |arm | ++----------------------------------+----------------------------+------------------+--------------+ +| macOS 11 Big Sur | 3.7, 3.8, 3.9, 3.10 | 8.4.0 |arm | +| +----------------------------+------------------+--------------+ +| | 3.7, 3.8, 3.9, 3.10, 3.11 | 9.4.0 |x86-64 | +| +----------------------------+------------------+ | +| | 3.6 | 8.4.0 | | ++----------------------------------+----------------------------+------------------+--------------+ +| macOS 10.15 Catalina | 3.6, 3.7, 3.8, 3.9 | 8.3.2 |x86-64 | +| +----------------------------+------------------+ | +| | 3.5 | 7.2.0 | | ++----------------------------------+----------------------------+------------------+--------------+ +| macOS 10.14 Mojave | 3.5, 3.6, 3.7, 3.8 | 7.2.0 |x86-64 | +| +----------------------------+------------------+ | +| | 2.7 | 6.0.0 | | +| +----------------------------+------------------+ | +| | 3.4 | 5.4.1 | | ++----------------------------------+----------------------------+------------------+--------------+ +| macOS 10.13 High Sierra | 2.7, 3.4, 3.5, 3.6 | 4.2.1 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| macOS 10.12 Sierra | 2.7, 3.4, 3.5, 3.6 | 4.1.1 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Mac OS X 10.11 El Capitan | 2.7, 3.4, 3.5, 3.6, 3.7 | 5.4.1 |x86-64 | +| +----------------------------+------------------+ | +| | 3.3 | 4.1.0 | | ++----------------------------------+----------------------------+------------------+--------------+ +| Mac OS X 10.9 Mavericks | 2.7, 3.2, 3.3, 3.4 | 3.0.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Mac OS X 10.8 Mountain Lion | 2.6, 2.7, 3.2, 3.3 | |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Redhat Linux 6 | 2.6 | |x86 | ++----------------------------------+----------------------------+------------------+--------------+ +| CentOS 6.3 | 2.7, 3.3 | |x86 | ++----------------------------------+----------------------------+------------------+--------------+ +| CentOS 8 | 3.9 | 9.0.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Fedora 23 | 2.7, 3.4 | 3.1.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Ubuntu Linux 12.04 LTS (Precise) | | 2.6, 3.2, 3.3, 3.4, 3.5 | 3.4.1 |x86,x86-64 | +| | | PyPy5.3.1, PyPy3 v2.4.0 | | | +| +----------------------------+------------------+--------------+ +| | 2.7 | 4.3.0 |x86-64 | +| +----------------------------+------------------+--------------+ +| | 2.7, 3.2 | 3.4.1 |ppc | ++----------------------------------+----------------------------+------------------+--------------+ +| Ubuntu Linux 10.04 LTS (Lucid) | 2.6 | 2.3.0 |x86,x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Debian 8.2 Jessie | 2.7, 3.4 | 3.1.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Raspbian Jessie | 2.7, 3.4 | 3.1.0 |arm | ++----------------------------------+----------------------------+------------------+--------------+ +| Raspbian Stretch | 2.7, 3.5 | 4.0.0 |arm | ++----------------------------------+----------------------------+------------------+--------------+ +| Raspberry Pi OS | 3.6, 3.7, 3.8, 3.9 | 8.2.0 |arm | +| +----------------------------+------------------+ | +| | 2.7 | 6.2.2 | | ++----------------------------------+----------------------------+------------------+--------------+ +| Gentoo Linux | 2.7, 3.2 | 2.1.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| FreeBSD 11.1 | 2.7, 3.4, 3.5, 3.6 | 4.3.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| FreeBSD 10.3 | 2.7, 3.4, 3.5 | 4.2.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| FreeBSD 10.2 | 2.7, 3.4 | 3.1.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows 11 | 3.9, 3.10, 3.11, 3.12 | 10.2.0 |arm64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows 11 Pro | 3.11, 3.12 | 10.2.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows 10 | 3.7 | 7.1.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows 10/Cygwin 3.3 | 3.6, 3.7, 3.8, 3.9 | 8.4.0 |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows 8.1 Pro | 2.6, 2.7, 3.2, 3.3, 3.4 | 2.4.0 |x86,x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows 8 Pro | 2.6, 2.7, 3.2, 3.3, 3.4a3 | 2.2.0 |x86,x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows 7 Professional | 3.7 | 7.0.0 |x86,x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ +| Windows Server 2008 R2 Enterprise| 3.3 | |x86-64 | ++----------------------------------+----------------------------+------------------+--------------+ diff --git a/docs/installation/python-support.rst b/docs/installation/python-support.rst new file mode 100644 index 000000000..dd5765b6b --- /dev/null +++ b/docs/installation/python-support.rst @@ -0,0 +1,14 @@ +.. _python-support: + +Python Support +============== + +Pillow supports these Python versions. + +.. csv-table:: Newer versions + :file: newer-versions.csv + :header-rows: 1 + +.. csv-table:: Older versions + :file: older-versions.csv + :header-rows: 1 diff --git a/docs/reference/ExifTags.rst b/docs/reference/ExifTags.rst index 464ab77ea..06965ead3 100644 --- a/docs/reference/ExifTags.rst +++ b/docs/reference/ExifTags.rst @@ -4,8 +4,9 @@ :py:mod:`~PIL.ExifTags` Module ============================== -The :py:mod:`~PIL.ExifTags` module exposes several ``enum.IntEnum`` classes -which provide constants and clear-text names for various well-known EXIF tags. +The :py:mod:`~PIL.ExifTags` module exposes several :py:class:`enum.IntEnum` +classes which provide constants and clear-text names for various well-known +EXIF tags. .. py:data:: Base diff --git a/docs/reference/ImageCms.rst b/docs/reference/ImageCms.rst index 9b9b5e7b2..c4484cbe2 100644 --- a/docs/reference/ImageCms.rst +++ b/docs/reference/ImageCms.rst @@ -8,9 +8,34 @@ The :py:mod:`~PIL.ImageCms` module provides color profile management support using the LittleCMS2 color management engine, based on Kevin Cazabon's PyCMS library. +.. autoclass:: ImageCmsProfile + :members: + :special-members: __init__ .. autoclass:: ImageCmsTransform + :members: + :undoc-members: + :show-inheritance: .. autoexception:: PyCMSError +Constants +--------- + +.. autoclass:: Intent + :members: + :member-order: bysource + :undoc-members: + :show-inheritance: +.. autoclass:: Direction + :members: + :member-order: bysource + :undoc-members: + :show-inheritance: +.. autoclass:: Flags + :members: + :member-order: bysource + :undoc-members: + :show-inheritance: + Functions --------- @@ -37,13 +62,15 @@ CmsProfile ---------- The ICC color profiles are wrapped in an instance of the class -:py:class:`CmsProfile`. The specification ICC.1:2010 contains more +:py:class:`~core.CmsProfile`. The specification ICC.1:2010 contains more information about the meaning of the values in ICC profiles. For convenience, all XYZ-values are also given as xyY-values (so they can be easily displayed in a chromaticity diagram, for example). +.. py:currentmodule:: PIL.ImageCms.core .. py:class:: CmsProfile + :canonical: PIL._imagingcms.CmsProfile .. py:attribute:: creation_date :type: Optional[datetime.datetime] diff --git a/docs/reference/ImageGrab.rst b/docs/reference/ImageGrab.rst index 0b94032d5..db2987eb0 100644 --- a/docs/reference/ImageGrab.rst +++ b/docs/reference/ImageGrab.rst @@ -11,9 +11,9 @@ or the clipboard to a PIL image memory. .. py:function:: grab(bbox=None, include_layered_windows=False, all_screens=False, xdisplay=None) - Take a snapshot of the screen. The pixels inside the bounding box are - returned as an "RGBA" on macOS, or an "RGB" image otherwise. - If the bounding box is omitted, the entire screen is copied. + Take a snapshot of the screen. The pixels inside the bounding box are returned as + an "RGBA" on macOS, or an "RGB" image otherwise. If the bounding box is omitted, + the entire screen is copied, and on macOS, it will be at 2x if on a Retina screen. On Linux, if ``xdisplay`` is ``None`` and the default X11 display does not return a snapshot of the screen, ``gnome-screenshot`` will be used as fallback if it is @@ -22,7 +22,10 @@ or the clipboard to a PIL image memory. .. versionadded:: 1.1.3 (Windows), 3.0.0 (macOS), 7.1.0 (Linux) :param bbox: What region to copy. Default is the entire screen. - Note that on Windows OS, the top-left point may be negative if ``all_screens=True`` is used. + On macOS, this is not increased to 2x for Retina screens, so the full + width of a Retina screen would be 1440, not 2880. + On Windows, the top-left point may be negative if ``all_screens=True`` + is used. :param include_layered_windows: Includes layered windows. Windows OS only. .. versionadded:: 6.1.0 diff --git a/docs/reference/ImageOps.rst b/docs/reference/ImageOps.rst index 475253078..051fdcfc9 100644 --- a/docs/reference/ImageOps.rst +++ b/docs/reference/ImageOps.rst @@ -14,6 +14,8 @@ only work on L and RGB images. .. autofunction:: colorize .. autofunction:: crop .. autofunction:: scale +.. autoclass:: SupportsGetMesh + :show-inheritance: .. autofunction:: deform .. autofunction:: equalize .. autofunction:: expand diff --git a/docs/reference/ImageTransform.rst b/docs/reference/ImageTransform.rst new file mode 100644 index 000000000..5b0a5ce49 --- /dev/null +++ b/docs/reference/ImageTransform.rst @@ -0,0 +1,40 @@ + +.. py:module:: PIL.ImageTransform +.. py:currentmodule:: PIL.ImageTransform + +:py:mod:`~PIL.ImageTransform` Module +==================================== + +The :py:mod:`~PIL.ImageTransform` module contains implementations of +:py:class:`~PIL.Image.ImageTransformHandler` for some of the builtin +:py:class:`.Image.Transform` methods. + +.. autoclass:: Transform + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: AffineTransform + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: PerspectiveTransform + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: ExtentTransform + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: QuadTransform + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: MeshTransform + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 5d6affa94..82c75e373 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -25,6 +25,7 @@ Reference ImageShow ImageStat ImageTk + ImageTransform ImageWin ExifTags TiffTags diff --git a/docs/reference/internal_design.rst b/docs/reference/internal_design.rst index 2e2d3322f..99a18e9ea 100644 --- a/docs/reference/internal_design.rst +++ b/docs/reference/internal_design.rst @@ -1,5 +1,5 @@ -Internal Reference Docs -======================= +Internal Reference +================== .. toctree:: :maxdepth: 2 diff --git a/docs/reference/internal_modules.rst b/docs/reference/internal_modules.rst index 363a67d9b..899e4966f 100644 --- a/docs/reference/internal_modules.rst +++ b/docs/reference/internal_modules.rst @@ -25,6 +25,27 @@ Internal Modules :undoc-members: :show-inheritance: +:mod:`~PIL._typing` Module +-------------------------- + +.. module:: PIL._typing + +Provides a convenient way to import type hints that are not available +on some Python versions. + +.. py:class:: StrOrBytesPath + + Typing alias. + +.. py:class:: SupportsRead + + An object that supports the read method. + +.. py:data:: TypeGuard + :value: typing.TypeGuard + + See :py:obj:`typing.TypeGuard`. + :mod:`~PIL._util` Module ------------------------ diff --git a/docs/reference/open_files.rst b/docs/reference/open_files.rst index f31941c9a..730c8da5b 100644 --- a/docs/reference/open_files.rst +++ b/docs/reference/open_files.rst @@ -3,7 +3,7 @@ File Handling in Pillow ======================= -When opening a file as an image, Pillow requires a filename, ``pathlib.Path`` +When opening a file as an image, Pillow requires a filename, ``os.PathLike`` object, or a file-like object. Pillow uses the filename or ``Path`` to open a file, so for the rest of this article, they will all be treated as a file-like object. diff --git a/docs/releasenotes/10.0.0.rst b/docs/releasenotes/10.0.0.rst index a3f238119..705ca0415 100644 --- a/docs/releasenotes/10.0.0.rst +++ b/docs/releasenotes/10.0.0.rst @@ -43,7 +43,7 @@ Constants ^^^^^^^^^ A number of constants have been removed. -Instead, ``enum.IntEnum`` classes have been added. +Instead, :py:class:`enum.IntEnum` classes have been added. ===================================================== ============================================================ Removed Use instead diff --git a/docs/releasenotes/10.2.0.rst b/docs/releasenotes/10.2.0.rst index 9883f10ba..c3947f64c 100644 --- a/docs/releasenotes/10.2.0.rst +++ b/docs/releasenotes/10.2.0.rst @@ -1,14 +1,6 @@ 10.2.0 ------ -Backwards Incompatible Changes -============================== - -TODO -^^^^ - -TODO - Deprecations ============ @@ -20,10 +12,14 @@ ImageFile.raise_oserror error codes returned by a codec's ``decode()`` method, which ImageFile already does automatically. -TODO -^^^^ +IptcImageFile helper functions +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +The functions ``IptcImageFile.dump`` and ``IptcImageFile.i``, and the constant +``IptcImageFile.PAD`` have been deprecated and will be removed in Pillow +12.0.0 (2025-10-15). These are undocumented helper functions intended +for internal use, so there is no replacement. They can each be replaced +by a single line of code using builtin functions in Python. API Changes =========== @@ -46,6 +42,14 @@ Added DdsImagePlugin enums :py:class:`~PIL.DdsImagePlugin.DXGI_FORMAT` and :py:class:`~PIL.DdsImagePlugin.D3DFMT` enums have been added to :py:class:`PIL.DdsImagePlugin`. +JPEG RGB color space +^^^^^^^^^^^^^^^^^^^^ + +When saving JPEG files, ``keep_rgb`` can now be set to ``True``. This will store RGB +images in the RGB color space instead of being converted to YCbCr automatically by +libjpeg. When this option is enabled, attempting to chroma-subsample RGB images with +the ``subsampling`` option will raise an :py:exc:`OSError`. + JPEG restart marker interval ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -62,10 +66,34 @@ output only the quantization and Huffman tables for the image. Security ======== -TODO -^^^^ +ImageFont.getmask: Applied ImageFont.MAX_STRING_LENGTH +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +To protect against potential DOS attacks when using arbitrary strings as text input, +Pillow will now raise a :py:exc:`ValueError` if the number of characters passed into +:py:meth:`PIL.ImageFont.ImageFont.getmask` is over a certain limit, +:py:data:`PIL.ImageFont.MAX_STRING_LENGTH`. + +This threshold can be changed by setting :py:data:`PIL.ImageFont.MAX_STRING_LENGTH`. It +can be disabled by setting ``ImageFont.MAX_STRING_LENGTH = None``. + +A decompression bomb check has also been added to +:py:meth:`PIL.ImageFont.ImageFont.getmask`. + +ImageFont.getmask: Trim glyph size +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +To protect against potential DOS attacks when using PIL fonts, +:py:class:`PIL.ImageFont.ImageFont` now trims the size of individual glyphs so that +they do not extend beyond the bitmap image. + +ImageMath.eval: Restricted environment keys +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:cve:`2023-50447`: If an attacker has control over the keys passed to the +``environment`` argument of :py:meth:`PIL.ImageMath.eval`, they may be able to execute +arbitrary code. To prevent this, keys matching the names of builtins and keys +containing double underscores will now raise a :py:exc:`ValueError`. Other Changes ============= @@ -78,16 +106,56 @@ Support has been added to read the BC4U format of DDS images. Support has also been added to read DX10 BC1 and BC4, whether UNORM or TYPELESS. +Support arbitrary masks for uncompressed RGB DDS images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +All masks are now supported when reading DDS images with uncompressed RGB data, +allowing for bit counts other than 24 and 32. + +Saving TIFF tag RowsPerStrip +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When saving TIFF images, the TIFF tag RowsPerStrip can now be one of the tags set by +the user, rather than always being calculated by Pillow. + +Optimized ImageColor.getrgb and getcolor +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The color calculations of :py:attr:`~PIL.ImageColor.getrgb` and +:py:attr:`~PIL.ImageColor.getcolor` are now cached using +:py:func:`functools.lru_cache`. Cached calls of ``getrgb`` are 3.1 - 91.4 times +as fast and ``getcolor`` are 5.1 - 19.6 times as fast. + +Optimized ImageMode.getmode +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The lookups made by :py:attr:`~PIL.ImageMode.getmode` are now cached using +:py:func:`functools.lru_cache` instead of a custom cache. Cached calls are 1.2 times as +fast. + Optimized ImageStat.Stat count and extrema ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Calculating the :py:attr:`~PIL.ImageStat.Stat.count` and :py:attr:`~PIL.ImageStat.Stat.extrema` statistics is now faster. After the -histogram is created in ``st = ImageStat.Stat(im)``, ``st.count`` is 3x as fast -on average and ``st.extrema`` is 12x as fast on average. +histogram is created in ``st = ImageStat.Stat(im)``, ``st.count`` is 3 times as fast on +average and ``st.extrema`` is 12 times as fast on average. Encoder errors now report error detail as string ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ :py:exc:`OSError` exceptions from image encoders now include a textual description of the error instead of a numeric error code. + +Type hints +^^^^^^^^^^ + +Work has begun to add type annotations to Pillow, including: + +* :py:mod:`~PIL.ContainerIO` +* :py:mod:`~PIL.FontFile`, :py:mod:`~PIL.BdfFontFile` and :py:mod:`~PIL.PcfFontFile` +* :py:mod:`~PIL.ImageChops` +* :py:mod:`~PIL.ImageMode` +* :py:mod:`~PIL.ImageSequence` +* :py:mod:`~PIL.ImageTransform` +* :py:mod:`~PIL.TarIO` diff --git a/docs/releasenotes/10.3.0.rst b/docs/releasenotes/10.3.0.rst new file mode 100644 index 000000000..af31cdb74 --- /dev/null +++ b/docs/releasenotes/10.3.0.rst @@ -0,0 +1,87 @@ +10.3.0 +------ + +Backwards Incompatible Changes +============================== + +TODO +^^^^ + +Deprecations +============ + +ImageCms constants and versions() function +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +A number of constants and a function in :py:mod:`.ImageCms` have been deprecated. +This includes a table of flags based on LittleCMS version 1 which has been replaced +with a new class :py:class:`.ImageCms.Flags` based on LittleCMS 2 flags. + +============================================ ==================================================== +Deprecated Use instead +============================================ ==================================================== +``ImageCms.DESCRIPTION`` No replacement +``ImageCms.VERSION`` ``PIL.__version__`` +``ImageCms.FLAGS["MATRIXINPUT"]`` :py:attr:`.ImageCms.Flags.CLUT_POST_LINEARIZATION` +``ImageCms.FLAGS["MATRIXOUTPUT"]`` :py:attr:`.ImageCms.Flags.FORCE_CLUT` +``ImageCms.FLAGS["MATRIXONLY"]`` No replacement +``ImageCms.FLAGS["NOWHITEONWHITEFIXUP"]`` :py:attr:`.ImageCms.Flags.NOWHITEONWHITEFIXUP` +``ImageCms.FLAGS["NOPRELINEARIZATION"]`` :py:attr:`.ImageCms.Flags.CLUT_PRE_LINEARIZATION` +``ImageCms.FLAGS["GUESSDEVICECLASS"]`` :py:attr:`.ImageCms.Flags.GUESSDEVICECLASS` +``ImageCms.FLAGS["NOTCACHE"]`` :py:attr:`.ImageCms.Flags.NOCACHE` +``ImageCms.FLAGS["NOTPRECALC"]`` :py:attr:`.ImageCms.Flags.NOOPTIMIZE` +``ImageCms.FLAGS["NULLTRANSFORM"]`` :py:attr:`.ImageCms.Flags.NULLTRANSFORM` +``ImageCms.FLAGS["HIGHRESPRECALC"]`` :py:attr:`.ImageCms.Flags.HIGHRESPRECALC` +``ImageCms.FLAGS["LOWRESPRECALC"]`` :py:attr:`.ImageCms.Flags.LOWRESPRECALC` +``ImageCms.FLAGS["GAMUTCHECK"]`` :py:attr:`.ImageCms.Flags.GAMUTCHECK` +``ImageCms.FLAGS["WHITEBLACKCOMPENSATION"]`` :py:attr:`.ImageCms.Flags.BLACKPOINTCOMPENSATION` +``ImageCms.FLAGS["BLACKPOINTCOMPENSATION"]`` :py:attr:`.ImageCms.Flags.BLACKPOINTCOMPENSATION` +``ImageCms.FLAGS["SOFTPROOFING"]`` :py:attr:`.ImageCms.Flags.SOFTPROOFING` +``ImageCms.FLAGS["PRESERVEBLACK"]`` :py:attr:`.ImageCms.Flags.NONEGATIVES` +``ImageCms.FLAGS["NODEFAULTRESOURCEDEF"]`` :py:attr:`.ImageCms.Flags.NODEFAULTRESOURCEDEF` +``ImageCms.FLAGS["GRIDPOINTS"]`` :py:attr:`.ImageCms.Flags.GRIDPOINTS()` +``ImageCms.versions()`` :py:func:`PIL.features.version_module` with + ``feature="littlecms2"``, :py:data:`sys.version` or + :py:data:`sys.version_info`, and ``PIL.__version__`` +============================================ ==================================================== + +API Changes +=========== + +TODO +^^^^ + +TODO + +API Additions +============= + +Added PerspectiveTransform +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:class:`~PIL.ImageTransform.PerspectiveTransform` has been added, meaning +that all of the :py:data:`~PIL.Image.Transform` values now have a corresponding +subclass of :py:class:`~PIL.ImageTransform.Transform`. + +Security +======== + +TODO +^^^^ + +TODO + +Other Changes +============= + +Portable FloatMap (PFM) images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Support has been added for reading and writing grayscale (Pf format) +Portable FloatMap (PFM) files containing ``F`` data. + +Release GIL when fetching WebP frames +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Python's Global Interpreter Lock is now released when fetching WebP frames from +the libwebp decoder. diff --git a/docs/releasenotes/8.0.0.rst b/docs/releasenotes/8.0.0.rst index 2bf299dd3..1fc245c9a 100644 --- a/docs/releasenotes/8.0.0.rst +++ b/docs/releasenotes/8.0.0.rst @@ -30,7 +30,7 @@ Image.fromstring, im.fromstring and im.tostring ImageCms.CmsProfile attributes ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -Some attributes in :py:class:`PIL.ImageCms.CmsProfile` have been removed: +Some attributes in :py:class:`PIL.ImageCms.core.CmsProfile` have been removed: ======================== =================================================== Removed Use instead diff --git a/docs/releasenotes/9.1.0.rst b/docs/releasenotes/9.1.0.rst index 02da702a7..6400218f4 100644 --- a/docs/releasenotes/9.1.0.rst +++ b/docs/releasenotes/9.1.0.rst @@ -51,7 +51,7 @@ Constants ^^^^^^^^^ A number of constants have been deprecated and will be removed in Pillow 10.0.0 -(2023-07-01). Instead, ``enum.IntEnum`` classes have been added. +(2023-07-01). Instead, :py:class:`enum.IntEnum` classes have been added. .. note:: diff --git a/docs/releasenotes/9.2.0.rst b/docs/releasenotes/9.2.0.rst index b875edf8e..359a87e6f 100644 --- a/docs/releasenotes/9.2.0.rst +++ b/docs/releasenotes/9.2.0.rst @@ -69,10 +69,10 @@ Previous code:: im = Image.new("RGB", (100, 100)) draw = ImageDraw.Draw(im) - width, height = draw.textsize("Hello world") + width, height = draw.textsize("Hello world", font) width, height = font.getsize_multiline("Hello\nworld") - width, height = draw.multiline_textsize("Hello\nworld") + width, height = draw.multiline_textsize("Hello\nworld", font) Use instead:: @@ -84,11 +84,43 @@ Use instead:: im = Image.new("RGB", (100, 100)) draw = ImageDraw.Draw(im) - width = draw.textlength("Hello world") + width = draw.textlength("Hello world", font) - left, top, right, bottom = draw.multiline_textbbox((0, 0), "Hello\nworld") + left, top, right, bottom = draw.multiline_textbbox((0, 0), "Hello\nworld", font) width, height = right - left, bottom - top +Previously, the ``size`` methods returned a ``height`` that included the vertical +offset of the text, while the new ``bbox`` methods distinguish this as a ``top`` +offset. + +.. image:: ../example/size_vs_bbox.png + :alt: In bbox methods, top measures the vertical distance above the text, while bottom measures that plus the vertical distance of the text itself. In size methods, height also measures the vertical distance above the text plus the vertical distance of the text itself. + :align: center + +If you are using these methods for aligning text, consider using :ref:`text-anchors` instead +which avoid issues that can occur with non-English text or unusual fonts. +For example, instead of the following code:: + + from PIL import Image, ImageDraw, ImageFont + + font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + im = Image.new("RGB", (100, 100)) + draw = ImageDraw.Draw(im) + width, height = draw.textsize("Hello world", font) + x, y = (100 - width) / 2, (100 - height) / 2 + draw.text((x, y), "Hello world", font=font) + +Use instead:: + + from PIL import Image, ImageDraw, ImageFont + + font = ImageFont.truetype("Tests/fonts/FreeMono.ttf") + + im = Image.new("RGB", (100, 100)) + draw = ImageDraw.Draw(im) + draw.text((100 / 2, 100 / 2), "Hello world", font=font, anchor="mm") + API Additions ============= diff --git a/docs/releasenotes/9.3.0.rst b/docs/releasenotes/9.3.0.rst index fde2faae3..16075ce95 100644 --- a/docs/releasenotes/9.3.0.rst +++ b/docs/releasenotes/9.3.0.rst @@ -33,8 +33,9 @@ Added ExifTags enums ^^^^^^^^^^^^^^^^^^^^ The data from :py:data:`~PIL.ExifTags.TAGS` and -:py:data:`~PIL.ExifTags.GPSTAGS` is now also exposed as ``enum.IntEnum`` -classes: :py:data:`~PIL.ExifTags.Base` and :py:data:`~PIL.ExifTags.GPS`. +:py:data:`~PIL.ExifTags.GPSTAGS` is now also exposed as +:py:class:`enum.IntEnum` classes: :py:data:`~PIL.ExifTags.Base` and +:py:data:`~PIL.ExifTags.GPS`. Security diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index d8034853c..e86f8082b 100644 --- a/docs/releasenotes/index.rst +++ b/docs/releasenotes/index.rst @@ -14,6 +14,7 @@ expected to be backported to earlier versions. .. toctree:: :maxdepth: 2 + 10.3.0 10.2.0 10.1.0 10.0.1 diff --git a/pyproject.toml b/pyproject.toml index 193e8c9b2..518facc34 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ classifiers = [ "Topic :: Multimedia :: Graphics :: Capture :: Screen Capture", "Topic :: Multimedia :: Graphics :: Graphics Conversion", "Topic :: Multimedia :: Graphics :: Viewers", + "Typing :: Typed", ] dynamic = [ "version", @@ -65,6 +66,9 @@ tests = [ "pytest-cov", "pytest-timeout", ] +typing = [ + 'typing-extensions; python_version < "3.10"', +] xmp = [ "defusedxml", ] @@ -76,7 +80,6 @@ Homepage = "https://python-pillow.org" Mastodon = "https://fosstodon.org/@pillow" "Release notes" = "https://pillow.readthedocs.io/en/stable/releasenotes/index.html" Source = "https://github.com/python-pillow/Pillow" -Twitter = "https://twitter.com/PythonPillow" [tool.setuptools] packages = ["PIL"] @@ -93,7 +96,7 @@ config-settings = "raqm=enable raqm=vendor fribidi=vendor imagequant=disable" test-command = "cd {project} && .github/workflows/wheels-test.sh" test-extras = "tests" -[tool.ruff] +[tool.ruff.lint] select = [ "C4", # flake8-comprehensions "E", # pycodestyle errors @@ -101,24 +104,25 @@ select = [ "F", # pyflakes errors "I", # isort "ISC", # flake8-implicit-str-concat + "LOG", # flake8-logging "PGH", # pygrep-hooks "RUF100", # unused noqa (yesqa) "UP", # pyupgrade "W", # pycodestyle warnings "YTT", # flake8-2020 - # "LOG", # TODO: enable flake8-logging when it's not in preview anymore ] -extend-ignore = [ +ignore = [ "E203", # Whitespace before ':' "E221", # Multiple spaces before operator "E226", # Missing whitespace around arithmetic operator "E241", # Multiple spaces after ',' ] -[tool.ruff.per-file-ignores] -"Tests/*.py" = ["I001"] +[tool.ruff.lint.per-file-ignores] +"Tests/oss-fuzz/fuzz_font.py" = ["I002"] +"Tests/oss-fuzz/fuzz_pillow.py" = ["I002"] -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["PIL"] required-imports = ["from __future__ import annotations"] @@ -136,23 +140,3 @@ follow_imports = "silent" warn_redundant_casts = true warn_unreachable = true warn_unused_ignores = true -exclude = [ - '^src/PIL/_tkinter_finder.py$', - '^src/PIL/DdsImagePlugin.py$', - '^src/PIL/FpxImagePlugin.py$', - '^src/PIL/Image.py$', - '^src/PIL/ImageCms.py$', - '^src/PIL/ImageFile.py$', - '^src/PIL/ImageFont.py$', - '^src/PIL/ImageMath.py$', - '^src/PIL/ImageMorph.py$', - '^src/PIL/ImageQt.py$', - '^src/PIL/ImageShow.py$', - '^src/PIL/ImImagePlugin.py$', - '^src/PIL/MicImagePlugin.py$', - '^src/PIL/PdfParser.py$', - '^src/PIL/PyAccess.py$', - '^src/PIL/TiffImagePlugin.py$', - '^src/PIL/TiffTags.py$', - '^src/PIL/WebPImagePlugin.py$', -] diff --git a/setup.py b/setup.py old mode 100755 new mode 100644 index 1bf0bcff5..ce8f989ab --- a/setup.py +++ b/setup.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # > pyroma . # ------------------------------ # Checking . @@ -570,6 +569,9 @@ class pil_build_ext(build_ext): if sdk_path: _add_directory(library_dirs, os.path.join(sdk_path, "usr", "lib")) _add_directory(include_dirs, os.path.join(sdk_path, "usr", "include")) + + for extension in self.extensions: + extension.extra_compile_args = ["-Wno-nullability-completeness"] elif ( sys.platform.startswith("linux") or sys.platform.startswith("gnu") diff --git a/src/PIL/BdfFontFile.py b/src/PIL/BdfFontFile.py index b12ddc2d4..e3eda4fe9 100644 --- a/src/PIL/BdfFontFile.py +++ b/src/PIL/BdfFontFile.py @@ -22,6 +22,8 @@ Parse X Bitmap Distribution Format (BDF) """ from __future__ import annotations +from typing import BinaryIO + from . import FontFile, Image bdf_slant = { @@ -36,7 +38,17 @@ bdf_slant = { bdf_spacing = {"P": "Proportional", "M": "Monospaced", "C": "Cell"} -def bdf_char(f): +def bdf_char( + f: BinaryIO, +) -> ( + tuple[ + str, + int, + tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]], + Image.Image, + ] + | None +): # skip to STARTCHAR while True: s = f.readline() @@ -56,13 +68,12 @@ def bdf_char(f): props[s[:i].decode("ascii")] = s[i + 1 : -1].decode("ascii") # load bitmap - bitmap = [] + bitmap = bytearray() while True: s = f.readline() if not s or s[:7] == b"ENDCHAR": break - bitmap.append(s[:-1]) - bitmap = b"".join(bitmap) + bitmap += s[:-1] # The word BBX # followed by the width in x (BBw), height in y (BBh), @@ -92,7 +103,7 @@ def bdf_char(f): class BdfFontFile(FontFile.FontFile): """Font file plugin for the X11 BDF format.""" - def __init__(self, fp): + def __init__(self, fp: BinaryIO): super().__init__() s = fp.readline() diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index b8f38b78a..f0fbc8cc2 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -28,6 +28,7 @@ BLP files come in many different flavours: - DXT3 compression is used if alpha_encoding == 1. - DXT5 compression is used if alpha_encoding == 7. """ + from __future__ import annotations import os diff --git a/src/PIL/ContainerIO.py b/src/PIL/ContainerIO.py index 64d042426..0035296a4 100644 --- a/src/PIL/ContainerIO.py +++ b/src/PIL/ContainerIO.py @@ -16,15 +16,16 @@ from __future__ import annotations import io +from typing import IO, AnyStr, Generic, Literal -class ContainerIO: +class ContainerIO(Generic[AnyStr]): """ A file object that provides read access to a part of an existing file (for example a TAR file). """ - def __init__(self, file, offset, length) -> None: + def __init__(self, file: IO[AnyStr], offset: int, length: int) -> None: """ Create file object. @@ -32,7 +33,7 @@ class ContainerIO: :param offset: Start of region, in bytes. :param length: Size of region, in bytes. """ - self.fh = file + self.fh: IO[AnyStr] = file self.pos = 0 self.offset = offset self.length = length @@ -41,10 +42,10 @@ class ContainerIO: ## # Always false. - def isatty(self): + def isatty(self) -> bool: return False - def seek(self, offset, mode=io.SEEK_SET): + def seek(self, offset: int, mode: Literal[0, 1, 2] = io.SEEK_SET) -> None: """ Move file pointer. @@ -63,7 +64,7 @@ class ContainerIO: self.pos = max(0, min(self.pos, self.length)) self.fh.seek(self.offset + self.pos) - def tell(self): + def tell(self) -> int: """ Get current file pointer. @@ -71,7 +72,7 @@ class ContainerIO: """ return self.pos - def read(self, n=0): + def read(self, n: int = 0) -> AnyStr: """ Read data. @@ -84,17 +85,17 @@ class ContainerIO: else: n = self.length - self.pos if not n: # EOF - return b"" if "b" in self.fh.mode else "" + return b"" if "b" in self.fh.mode else "" # type: ignore[return-value] self.pos = self.pos + n return self.fh.read(n) - def readline(self): + def readline(self) -> AnyStr: """ Read a line of text. :returns: An 8-bit string. """ - s = b"" if "b" in self.fh.mode else "" + s: AnyStr = b"" if "b" in self.fh.mode else "" # type: ignore[assignment] newline_character = b"\n" if "b" in self.fh.mode else "\n" while True: c = self.read(1) @@ -105,7 +106,7 @@ class ContainerIO: break return s - def readlines(self): + def readlines(self) -> list[AnyStr]: """ Read multiple lines of text. diff --git a/src/PIL/DdsImagePlugin.py b/src/PIL/DdsImagePlugin.py index 5b6ac2ead..be17f4223 100644 --- a/src/PIL/DdsImagePlugin.py +++ b/src/PIL/DdsImagePlugin.py @@ -9,6 +9,7 @@ The contents of this file are hereby released in the public domain (CC0) Full text of the CC0 license: https://creativecommons.org/publicdomain/zero/1.0/ """ + from __future__ import annotations import io @@ -18,6 +19,7 @@ from enum import IntEnum, IntFlag from . import Image, ImageFile, ImagePalette from ._binary import i32le as i32 +from ._binary import o8 from ._binary import o32le as o32 # Magic ("DDS ") @@ -268,13 +270,17 @@ class D3DFMT(IntEnum): # Backward compatibility layer module = sys.modules[__name__] for item in DDSD: + assert item.name is not None setattr(module, "DDSD_" + item.name, item.value) -for item in DDSCAPS: - setattr(module, "DDSCAPS_" + item.name, item.value) -for item in DDSCAPS2: - setattr(module, "DDSCAPS2_" + item.name, item.value) -for item in DDPF: - setattr(module, "DDPF_" + item.name, item.value) +for item1 in DDSCAPS: + assert item1.name is not None + setattr(module, "DDSCAPS_" + item1.name, item1.value) +for item2 in DDSCAPS2: + assert item2.name is not None + setattr(module, "DDSCAPS2_" + item2.name, item2.value) +for item3 in DDPF: + assert item3.name is not None + setattr(module, "DDPF_" + item3.name, item3.value) DDS_FOURCC = DDPF.FOURCC DDS_RGB = DDPF.RGB @@ -341,6 +347,7 @@ class DdsImageFile(ImageFile.ImageFile): flags, height, width = struct.unpack("<3I", header.read(12)) self._size = (width, height) + extents = (0, 0) + self.size pitch, depth, mipmaps = struct.unpack("<3I", header.read(12)) struct.unpack("<11I", header.read(44)) # reserved @@ -351,22 +358,16 @@ class DdsImageFile(ImageFile.ImageFile): rawmode = None if pfflags & DDPF.RGB: # Texture contains uncompressed RGB data - masks = struct.unpack("<4I", header.read(16)) - masks = {mask: ["R", "G", "B", "A"][i] for i, mask in enumerate(masks)} - if bitcount == 24: - self._mode = "RGB" - rawmode = masks[0x000000FF] + masks[0x0000FF00] + masks[0x00FF0000] - elif bitcount == 32 and pfflags & DDPF.ALPHAPIXELS: + if pfflags & DDPF.ALPHAPIXELS: self._mode = "RGBA" - rawmode = ( - masks[0x000000FF] - + masks[0x0000FF00] - + masks[0x00FF0000] - + masks[0xFF000000] - ) + mask_count = 4 else: - msg = f"Unsupported bitcount {bitcount} for {pfflags}" - raise OSError(msg) + self._mode = "RGB" + mask_count = 3 + + masks = struct.unpack(f"<{mask_count}I", header.read(mask_count * 4)) + self.tile = [("dds_rgb", extents, 0, (bitcount, masks))] + return elif pfflags & DDPF.LUMINANCE: if bitcount == 8: self._mode = "L" @@ -464,7 +465,6 @@ class DdsImageFile(ImageFile.ImageFile): msg = f"Unknown pixel format flags {pfflags}" raise NotImplementedError(msg) - extents = (0, 0) + self.size if n: self.tile = [ ImageFile._Tile("bcn", extents, offset, (n, self.pixel_format)) @@ -476,6 +476,39 @@ class DdsImageFile(ImageFile.ImageFile): pass +class DdsRgbDecoder(ImageFile.PyDecoder): + _pulls_fd = True + + def decode(self, buffer): + bitcount, masks = self.args + + # Some masks will be padded with zeros, e.g. R 0b11 G 0b1100 + # Calculate how many zeros each mask is padded with + mask_offsets = [] + # And the maximum value of each channel without the padding + mask_totals = [] + for mask in masks: + offset = 0 + if mask != 0: + while mask >> (offset + 1) << (offset + 1) == mask: + offset += 1 + mask_offsets.append(offset) + mask_totals.append(mask >> offset) + + data = bytearray() + bytecount = bitcount // 8 + while len(data) < self.state.xsize * self.state.ysize * len(masks): + value = int.from_bytes(self.fd.read(bytecount), "little") + for i, mask in enumerate(masks): + masked_value = value & mask + # Remove the zero padding, and scale it to 8 bits + data += o8( + int(((masked_value >> mask_offsets[i]) / mask_totals[i]) * 255) + ) + self.set_as_raw(bytes(data)) + return -1, 0 + + def _save(im, fp, filename): if im.mode not in ("RGB", "RGBA", "L", "LA"): msg = f"cannot write mode {im.mode} as DDS" @@ -533,5 +566,6 @@ def _accept(prefix): Image.register_open(DdsImageFile.format, DdsImageFile, _accept) +Image.register_decoder("dds_rgb", DdsRgbDecoder) Image.register_save(DdsImageFile.format, _save) Image.register_extension(DdsImageFile.format, ".dds") diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index d2e60aa07..523ffcbf7 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -38,7 +38,7 @@ from ._deprecate import deprecate split = re.compile(r"^%%([^:]*):[ \t]*(.*)[ \t]*$") field = re.compile(r"^%[%!\w]([^:]*)[ \t]*$") -gs_binary = None +gs_binary: str | bool | None = None gs_windows_binary = None @@ -356,14 +356,10 @@ class EpsImageFile(ImageFile.ImageFile): self._size = columns, rows return + elif bytes_mv[:5] == b"%%EOF": + break elif trailer_reached and reading_trailer_comments: # Load EPS trailer - - # if this line starts with "%%EOF", - # then we've reached the end of the file - if bytes_mv[:5] == b"%%EOF": - break - s = str(bytes_mv[:bytes_read], "latin-1") _read_comment(s) elif bytes_mv[:9] == b"%%Trailer": @@ -408,7 +404,7 @@ class EpsImageFile(ImageFile.ImageFile): self.tile = [] return Image.Image.load(self) - def load_seek(self, *args, **kwargs): + def load_seek(self, pos): # we can't incrementally load, so force ImageFile.parser to # use our custom load method by defining this method. pass diff --git a/src/PIL/FitsImagePlugin.py b/src/PIL/FitsImagePlugin.py index 7dce2d60f..e69890bab 100644 --- a/src/PIL/FitsImagePlugin.py +++ b/src/PIL/FitsImagePlugin.py @@ -15,7 +15,7 @@ import math from . import Image, ImageFile -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:6] == b"SIMPLE" @@ -23,8 +23,10 @@ class FitsImageFile(ImageFile.ImageFile): format = "FITS" format_description = "FITS" - def _open(self): - headers = {} + def _open(self) -> None: + assert self.fp is not None + + headers: dict[bytes, bytes] = {} while True: header = self.fp.read(80) if not header: diff --git a/src/PIL/FliImagePlugin.py b/src/PIL/FliImagePlugin.py index 9769761fc..f9e4c731c 100644 --- a/src/PIL/FliImagePlugin.py +++ b/src/PIL/FliImagePlugin.py @@ -77,6 +77,7 @@ class FliImageFile(ImageFile.ImageFile): if i16(s, 4) == 0xF100: # prefix chunk; ignore it self.__offset = self.__offset + i32(s) + self.fp.seek(self.__offset) s = self.fp.read(16) if i16(s, 4) == 0xF1FA: diff --git a/src/PIL/FontFile.py b/src/PIL/FontFile.py index 9621770e2..1e0c1c166 100644 --- a/src/PIL/FontFile.py +++ b/src/PIL/FontFile.py @@ -16,13 +16,16 @@ from __future__ import annotations import os +from typing import BinaryIO from . import Image, _binary WIDTH = 800 -def puti16(fp, values): +def puti16( + fp: BinaryIO, values: tuple[int, int, int, int, int, int, int, int, int, int] +) -> None: """Write network order (big-endian) 16-bit sequence""" for v in values: if v < 0: @@ -33,16 +36,32 @@ def puti16(fp, values): class FontFile: """Base class for raster font file handlers.""" - bitmap = None + bitmap: Image.Image | None = None - def __init__(self): - self.info = {} - self.glyph = [None] * 256 + def __init__(self) -> None: + self.info: dict[bytes, bytes | int] = {} + self.glyph: list[ + tuple[ + tuple[int, int], + tuple[int, int, int, int], + tuple[int, int, int, int], + Image.Image, + ] + | None + ] = [None] * 256 - def __getitem__(self, ix): + def __getitem__(self, ix: int) -> ( + tuple[ + tuple[int, int], + tuple[int, int, int, int], + tuple[int, int, int, int], + Image.Image, + ] + | None + ): return self.glyph[ix] - def compile(self): + def compile(self) -> None: """Create metrics and bitmap""" if self.bitmap: @@ -51,7 +70,7 @@ class FontFile: # create bitmap large enough to hold all data h = w = maxwidth = 0 lines = 1 - for glyph in self: + for glyph in self.glyph: if glyph: d, dst, src, im = glyph h = max(h, src[3] - src[1]) @@ -65,13 +84,16 @@ class FontFile: ysize = lines * h if xsize == 0 and ysize == 0: - return "" + return self.ysize = h # paste glyphs into bitmap self.bitmap = Image.new("1", (xsize, ysize)) - self.metrics = [None] * 256 + self.metrics: list[ + tuple[tuple[int, int], tuple[int, int, int, int], tuple[int, int, int, int]] + | None + ] = [None] * 256 x = y = 0 for i in range(256): glyph = self[i] @@ -88,12 +110,15 @@ class FontFile: self.bitmap.paste(im.crop(src), s) self.metrics[i] = d, dst, s - def save(self, filename): + def save(self, filename: str) -> None: """Save font""" self.compile() # font data + if not self.bitmap: + msg = "No bitmap created" + raise ValueError(msg) self.bitmap.save(os.path.splitext(filename)[0] + ".pbm", "PNG") # font metrics @@ -104,6 +129,6 @@ class FontFile: for id in range(256): m = self.metrics[id] if not m: - puti16(fp, [0] * 10) + puti16(fp, (0,) * 10) else: puti16(fp, m[0] + m[1] + m[2]) diff --git a/src/PIL/FtexImagePlugin.py b/src/PIL/FtexImagePlugin.py index d5513a56a..b4488e6ee 100644 --- a/src/PIL/FtexImagePlugin.py +++ b/src/PIL/FtexImagePlugin.py @@ -50,6 +50,7 @@ bytes for that mipmap level. Note: All data is stored in little-Endian (Intel) byte order. """ + from __future__ import annotations import struct diff --git a/src/PIL/GdImageFile.py b/src/PIL/GdImageFile.py index d84876eb6..88b87a22c 100644 --- a/src/PIL/GdImageFile.py +++ b/src/PIL/GdImageFile.py @@ -27,9 +27,12 @@ """ from __future__ import annotations +from typing import IO + from . import ImageFile, ImagePalette, UnidentifiedImageError from ._binary import i16be as i16 from ._binary import i32be as i32 +from ._typing import StrOrBytesPath class GdImageFile(ImageFile.ImageFile): @@ -43,8 +46,10 @@ class GdImageFile(ImageFile.ImageFile): format = "GD" format_description = "GD uncompressed images" - def _open(self): + def _open(self) -> None: # Header + assert self.fp is not None + s = self.fp.read(1037) if i16(s) not in [65534, 65535]: @@ -76,7 +81,7 @@ class GdImageFile(ImageFile.ImageFile): ] -def open(fp, mode="r"): +def open(fp: StrOrBytesPath | IO[bytes], mode: str = "r") -> GdImageFile: """ Load texture from a GD image file. diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 57d87078b..b8671068d 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -629,7 +629,7 @@ def _write_multiple_frames(im, fp, palette): "duration" ] continue - if encoderinfo.get("disposal") == 2: + if im_frames[-1]["encoderinfo"].get("disposal") == 2: if background_im is None: color = im.encoderinfo.get( "transparency", im.info.get("transparency", (0, 0, 0)) @@ -637,21 +637,19 @@ def _write_multiple_frames(im, fp, palette): background = _get_background(im_frame, color) background_im = Image.new("P", im_frame.size, background) background_im.putpalette(im_frames[0]["im"].palette) - delta, bbox = _getbbox(background_im, im_frame) - if encoderinfo.get("optimize") and im_frame.mode != "1": + bbox = _getbbox(background_im, im_frame)[1] + elif encoderinfo.get("optimize") and im_frame.mode != "1": if "transparency" not in encoderinfo: try: - encoderinfo[ - "transparency" - ] = im_frame.palette._new_color_index(im_frame) + encoderinfo["transparency"] = ( + im_frame.palette._new_color_index(im_frame) + ) except ValueError: pass if "transparency" in encoderinfo: # When the delta is zero, fill the image with transparency diff_frame = im_frame.copy() - fill = Image.new( - "P", diff_frame.size, encoderinfo["transparency"] - ) + fill = Image.new("P", delta.size, encoderinfo["transparency"]) if delta.mode == "RGBA": r, g, b, a = delta.split() mask = ImageMath.eval( diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index 1b22f8645..d66fbc287 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -329,6 +329,8 @@ class IcoImageFile(ImageFile.ImageFile): self.im = im.im self.pyaccess = None self._mode = im.mode + if im.palette: + self.palette = im.palette if im.size != self.size: warnings.warn("Image was not the expected size") @@ -339,7 +341,7 @@ class IcoImageFile(ImageFile.ImageFile): self.size = im.size - def load_seek(self): + def load_seek(self, pos): # Flag the ImageFile.Parser so that it # just does all the decode at the end. pass diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 97d726a8a..4613e40b6 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -93,8 +93,8 @@ for i in ["16", "16L", "16B"]: for i in ["32S"]: OPEN[f"L {i} image"] = ("I", f"I;{i}") OPEN[f"L*{i} image"] = ("I", f"I;{i}") -for i in range(2, 33): - OPEN[f"L*{i} image"] = ("F", f"F;{i}") +for j in range(2, 33): + OPEN[f"L*{j} image"] = ("F", f"F;{j}") # -------------------------------------------------------------------- diff --git a/src/PIL/Image.py b/src/PIL/Image.py index d04801cba..2e28c6868 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -26,6 +26,7 @@ from __future__ import annotations +import abc import atexit import builtins import io @@ -39,12 +40,8 @@ import tempfile import warnings from collections.abc import Callable, MutableMapping from enum import IntEnum -from pathlib import Path - -try: - from defusedxml import ElementTree -except ImportError: - ElementTree = None +from types import ModuleType +from typing import IO, TYPE_CHECKING, Any # VERSION was removed in Pillow 6.0.0. # PILLOW_VERSION was removed in Pillow 9.0.0. @@ -60,6 +57,12 @@ from . import ( from ._binary import i32le, o32be, o32le from ._util import DeferredError, is_path +ElementTree: ModuleType | None +try: + from defusedxml import ElementTree +except ImportError: + ElementTree = None + logger = logging.getLogger(__name__) @@ -72,7 +75,7 @@ class DecompressionBombError(Exception): # Limit to around a quarter gigabyte for a 24-bit (3 bpp) image -MAX_IMAGE_PIXELS = int(1024 * 1024 * 1024 // 4 // 3) +MAX_IMAGE_PIXELS: int | None = int(1024 * 1024 * 1024 // 4 // 3) try: @@ -92,7 +95,7 @@ try: raise ImportError(msg) except ImportError as v: - core = DeferredError(ImportError("The _imaging C module is not installed.")) + core = DeferredError.new(ImportError("The _imaging C module is not installed.")) # Explanations for ways that we know we might have an import error if str(v).startswith("Module use of python"): # The _imaging C module is present, but not compiled for @@ -110,6 +113,7 @@ except ImportError as v: USE_CFFI_ACCESS = False +cffi: ModuleType | None try: import cffi except ImportError: @@ -211,14 +215,22 @@ if hasattr(core, "DEFAULT_STRATEGY"): # -------------------------------------------------------------------- # Registries -ID = [] -OPEN = {} -MIME = {} -SAVE = {} -SAVE_ALL = {} -EXTENSION = {} -DECODERS = {} -ENCODERS = {} +if TYPE_CHECKING: + from . import ImageFile +ID: list[str] = [] +OPEN: dict[ + str, + tuple[ + Callable[[IO[bytes], str | bytes], ImageFile.ImageFile], + Callable[[bytes], bool] | None, + ], +] = {} +MIME: dict[str, str] = {} +SAVE: dict[str, Callable[[Image, IO[bytes], str | bytes], None]] = {} +SAVE_ALL: dict[str, Callable[[Image, IO[bytes], str | bytes], None]] = {} +EXTENSION: dict[str, str] = {} +DECODERS: dict[str, type[ImageFile.PyDecoder]] = {} +ENCODERS: dict[str, type[ImageFile.PyEncoder]] = {} # -------------------------------------------------------------------- # Modes @@ -242,7 +254,7 @@ MODES = ["1", "CMYK", "F", "HSV", "I", "L", "LAB", "P", "RGB", "RGBA", "RGBX", " _MAPMODES = ("L", "P", "RGBX", "RGBA", "CMYK", "I;16", "I;16L", "I;16B") -def getmodebase(mode): +def getmodebase(mode: str) -> str: """ Gets the "base" mode for given mode. This function returns "L" for images that contain grayscale data, and "RGB" for images that @@ -282,7 +294,7 @@ def getmodebandnames(mode): return ImageMode.getmode(mode).bands -def getmodebands(mode): +def getmodebands(mode: str) -> int: """ Gets the number of individual bands for this mode. @@ -530,15 +542,19 @@ class Image: def __enter__(self): return self + def _close_fp(self): + if getattr(self, "_fp", False): + if self._fp != self.fp: + self._fp.close() + self._fp = DeferredError(ValueError("Operation on closed image")) + if self.fp: + self.fp.close() + def __exit__(self, *args): - if hasattr(self, "fp") and getattr(self, "_exclusive_fp", False): - if getattr(self, "_fp", False): - if self._fp != self.fp: - self._fp.close() - self._fp = DeferredError(ValueError("Operation on closed image")) - if self.fp: - self.fp.close() - self.fp = None + if hasattr(self, "fp"): + if getattr(self, "_exclusive_fp", False): + self._close_fp() + self.fp = None def close(self): """ @@ -554,12 +570,7 @@ class Image: """ if hasattr(self, "fp"): try: - if getattr(self, "_fp", False): - if self._fp != self.fp: - self._fp.close() - self._fp = DeferredError(ValueError("Operation on closed image")) - if self.fp: - self.fp.close() + self._close_fp() self.fp = None except Exception as msg: logger.debug("Error closing: %s", msg) @@ -572,7 +583,7 @@ class Image: # object is gone. self.im = DeferredError(ValueError("Operation on closed image")) - def _copy(self): + def _copy(self) -> None: self.load() self.im = self.im.copy() self.pyaccess = None @@ -584,7 +595,9 @@ class Image: else: self.load() - def _dump(self, file=None, format=None, **options): + def _dump( + self, file: str | None = None, format: str | None = None, **options + ) -> str: suffix = "" if format: suffix = "." + format @@ -709,7 +722,7 @@ class Image: self.putpalette(palette) self.frombytes(data) - def tobytes(self, encoder_name="raw", *args): + def tobytes(self, encoder_name: str = "raw", *args) -> bytes: """ Return image as a bytes object. @@ -787,7 +800,7 @@ class Image: ] ) - def frombytes(self, data, decoder_name="raw", *args): + def frombytes(self, data: bytes, decoder_name: str = "raw", *args) -> None: """ Loads this image with pixel data from a bytes object. @@ -874,7 +887,7 @@ class Image: def convert( self, mode=None, matrix=None, dither=None, palette=Palette.WEB, colors=256 - ): + ) -> Image: """ Returns a converted copy of this image. For the "P" mode, this method translates pixels through the palette. If mode is @@ -965,7 +978,7 @@ class Image: delete_trns = False # transparency handling if has_transparency: - if (self.mode in ("1", "L", "I") and mode in ("LA", "RGBA")) or ( + if (self.mode in ("1", "L", "I", "I;16") and mode in ("LA", "RGBA")) or ( self.mode == "RGB" and mode == "RGBA" ): # Use transparent conversion to promote from transparent @@ -1194,7 +1207,7 @@ class Image: __copy__ = copy - def crop(self, box=None): + def crop(self, box=None) -> Image: """ Returns a rectangular region from this image. The box is a 4-tuple defining the left, upper, right, and lower pixel @@ -1296,7 +1309,7 @@ class Image: ] return merge(self.mode, ims) - def getbands(self): + def getbands(self) -> tuple[str, ...]: """ Returns a tuple containing the name of each band in this image. For example, ``getbands`` on an RGB image returns ("R", "G", "B"). @@ -1306,7 +1319,7 @@ class Image: """ return ImageMode.getmode(self.mode).bands - def getbbox(self, *, alpha_only=True): + def getbbox(self, *, alpha_only: bool = True) -> tuple[int, int, int, int]: """ Calculates the bounding box of the non-zero regions in the image. @@ -1417,7 +1430,7 @@ class Image: root = ElementTree.fromstring(xmp_tags) return {get_name(root.tag): get_value(root)} - def getexif(self): + def getexif(self) -> Exif: """ Gets EXIF data from the image. @@ -1425,7 +1438,6 @@ class Image: """ if self._exif is None: self._exif = Exif() - self._exif._loaded = False elif self._exif._loaded: return self._exif self._exif._loaded = True @@ -1512,7 +1524,7 @@ class Image: self.load() return self.im.ptr - def getpalette(self, rawmode="RGB"): + def getpalette(self, rawmode: str | None = "RGB") -> list[int] | None: """ Returns the image palette as a list. @@ -1602,7 +1614,7 @@ class Image: x, y = self.im.getprojection() return list(x), list(y) - def histogram(self, mask=None, extrema=None): + def histogram(self, mask=None, extrema=None) -> list[int]: """ Returns a histogram for the image. The histogram is returned as a list of pixel counts, one for each pixel value in the source @@ -1659,7 +1671,7 @@ class Image: return self.im.entropy(extrema) return self.im.entropy() - def paste(self, im, box=None, mask=None): + def paste(self, im, box=None, mask=None) -> None: """ Pastes another image into this image. The box argument is either a 2-tuple giving the upper left corner, a 4-tuple defining the @@ -1791,7 +1803,7 @@ class Image: result = alpha_composite(background, overlay) self.paste(result, box) - def point(self, lut, mode=None): + def point(self, lut, mode: str | None = None) -> Image: """ Maps this image through a lookup table or function. @@ -1915,7 +1927,7 @@ class Image: self.im.putdata(data, scale, offset) - def putpalette(self, data, rawmode="RGB"): + def putpalette(self, data, rawmode="RGB") -> None: """ Attaches a palette to this image. The image must be a "P", "PA", "L" or "LA" image. @@ -2095,7 +2107,7 @@ class Image: min(self.size[1], math.ceil(box[3] + support_y)), ) - def resize(self, size, resample=None, box=None, reducing_gap=None): + def resize(self, size, resample=None, box=None, reducing_gap=None) -> Image: """ Returns a resized copy of this image. @@ -2187,10 +2199,11 @@ class Image: if factor_x > 1 or factor_y > 1: reduce_box = self._get_safe_box(size, resample, box) factor = (factor_x, factor_y) - if callable(self.reduce): - self = self.reduce(factor, box=reduce_box) - else: - self = Image.reduce(self, factor, box=reduce_box) + self = ( + self.reduce(factor, box=reduce_box) + if callable(self.reduce) + else Image.reduce(self, factor, box=reduce_box) + ) box = ( (box[0] - reduce_box[0]) / factor_x, (box[1] - reduce_box[1]) / factor_y, @@ -2352,7 +2365,7 @@ class Image: (w, h), Transform.AFFINE, matrix, resample, fillcolor=fillcolor ) - def save(self, fp, format=None, **params): + def save(self, fp, format=None, **params) -> None: """ Saves this image under the given filename. If no format is specified, the format to use is determined from the filename @@ -2369,7 +2382,7 @@ class Image: implement the ``seek``, ``tell``, and ``write`` methods, and be opened in binary mode. - :param fp: A filename (string), pathlib.Path object or file object. + :param fp: A filename (string), os.PathLike object or file object. :param format: Optional format override. If omitted, the format to use is determined from the filename extension. If a file object was used instead of a filename, this @@ -2382,13 +2395,10 @@ class Image: may have been created, and may contain partial data. """ - filename = "" + filename: str | bytes = "" open_fp = False - if isinstance(fp, Path): - filename = str(fp) - open_fp = True - elif is_path(fp): - filename = fp + if is_path(fp): + filename = os.path.realpath(os.fspath(fp)) open_fp = True elif fp == sys.stdout: try: @@ -2397,7 +2407,7 @@ class Image: pass if not filename and hasattr(fp, "name") and is_path(fp.name): # only set the name for metadata purposes - filename = fp.name + filename = os.path.realpath(os.fspath(fp.name)) # may mutate self! self._ensure_mutable() @@ -2408,7 +2418,8 @@ class Image: preinit() - ext = os.path.splitext(filename)[1].lower() + filename_ext = os.path.splitext(filename)[1].lower() + ext = filename_ext.decode() if isinstance(filename_ext, bytes) else filename_ext if not format: if ext not in EXTENSION: @@ -2450,7 +2461,7 @@ class Image: if open_fp: fp.close() - def seek(self, frame) -> Image: + def seek(self, frame) -> None: """ Seeks to the given frame in this sequence file. If you seek beyond the end of the sequence, the method raises an @@ -2494,7 +2505,7 @@ class Image: _show(self, title=title) - def split(self): + def split(self) -> tuple[Image, ...]: """ Split this image into individual bands. This method returns a tuple of individual image bands from an image. For example, @@ -2510,10 +2521,8 @@ class Image: self.load() if self.im.bands == 1: - ims = [self.copy()] - else: - ims = map(self._new, self.im.split()) - return tuple(ims) + return (self.copy(),) + return tuple(map(self._new, self.im.split())) def getchannel(self, channel): """ @@ -2644,7 +2653,7 @@ class Image: resample=Resampling.NEAREST, fill=1, fillcolor=None, - ): + ) -> Image: """ Transforms this image. This method creates a new image with the given size, and the same mode as the original, and copies data @@ -2667,6 +2676,10 @@ class Image: def transform(self, size, data, resample, fill=1): # Return result + Implementations of :py:class:`~PIL.Image.ImageTransformHandler` + for some of the :py:class:`Transform` methods are provided + in :py:mod:`~PIL.ImageTransform`. + It may also be an object with a ``method.getdata`` method that returns a tuple supplying new ``method`` and ``data`` values:: @@ -2805,7 +2818,7 @@ class Image: self.im.transform2(box, image.im, method, data, resample, fill) - def transpose(self, method): + def transpose(self, method: Transpose) -> Image: """ Transpose image (flip or rotate in 90 degree steps) @@ -2857,7 +2870,9 @@ class ImagePointHandler: (for use with :py:meth:`~PIL.Image.Image.point`) """ - pass + @abc.abstractmethod + def point(self, im: Image) -> Image: + pass class ImageTransformHandler: @@ -2866,7 +2881,14 @@ class ImageTransformHandler: (for use with :py:meth:`~PIL.Image.Image.transform`) """ - pass + @abc.abstractmethod + def transform( + self, + size: tuple[int, int], + image: Image, + **options: dict[str, str | int | tuple[int, ...] | list[int]], + ) -> Image: + pass # -------------------------------------------------------------------- @@ -2903,7 +2925,7 @@ def _check_size(size): return True -def new(mode, size, color=0): +def new(mode, size, color=0) -> Image: """ Creates a new image with the given mode and size. @@ -2942,7 +2964,7 @@ def new(mode, size, color=0): return im._new(core.fill(mode, size, color)) -def frombytes(mode, size, data, decoder_name="raw", *args): +def frombytes(mode, size, data, decoder_name="raw", *args) -> Image: """ Creates a copy of an image memory from pixel data in a buffer. @@ -3191,7 +3213,7 @@ def _decompression_bomb_check(size): ) -def open(fp, mode="r", formats=None): +def open(fp, mode="r", formats=None) -> Image: """ Opens and identifies the given image file. @@ -3201,7 +3223,7 @@ def open(fp, mode="r", formats=None): :py:meth:`~PIL.Image.Image.load` method). See :py:func:`~PIL.Image.new`. See :ref:`file-handling`. - :param fp: A filename (string), pathlib.Path object or a file object. + :param fp: A filename (string), os.PathLike object or a file object. The file object must implement ``file.read``, ``file.seek``, and ``file.tell`` methods, and be opened in binary mode. The file object will also seek to zero @@ -3238,11 +3260,9 @@ def open(fp, mode="r", formats=None): raise TypeError(msg) exclusive_fp = False - filename = "" - if isinstance(fp, Path): - filename = str(fp.resolve()) - elif is_path(fp): - filename = fp + filename: str | bytes = "" + if is_path(fp): + filename = os.path.realpath(os.fspath(fp)) if filename: fp = builtins.open(filename, "rb") @@ -3416,7 +3436,11 @@ def merge(mode, bands): # Plugin registry -def register_open(id, factory, accept=None): +def register_open( + id, + factory: Callable[[IO[bytes], str | bytes], ImageFile.ImageFile], + accept: Callable[[bytes], bool] | None = None, +) -> None: """ Register an image file plugin. This function should not be used in application code. @@ -3432,7 +3456,7 @@ def register_open(id, factory, accept=None): OPEN[id] = factory, accept -def register_mime(id, mimetype): +def register_mime(id: str, mimetype: str) -> None: """ Registers an image MIME type by populating ``Image.MIME``. This function should not be used in application code. @@ -3447,7 +3471,7 @@ def register_mime(id, mimetype): MIME[id.upper()] = mimetype -def register_save(id, driver): +def register_save(id: str, driver) -> None: """ Registers an image save function. This function should not be used in application code. @@ -3470,7 +3494,7 @@ def register_save_all(id, driver): SAVE_ALL[id.upper()] = driver -def register_extension(id, extension): +def register_extension(id, extension) -> None: """ Registers an image extension. This function should not be used in application code. @@ -3481,7 +3505,7 @@ def register_extension(id, extension): EXTENSION[extension.lower()] = id.upper() -def register_extensions(id, extensions): +def register_extensions(id, extensions) -> None: """ Registers image extensions. This function should not be used in application code. @@ -3502,28 +3526,26 @@ def registered_extensions(): return EXTENSION -def register_decoder(name, decoder): +def register_decoder(name: str, decoder: type[ImageFile.PyDecoder]) -> None: """ Registers an image decoder. This function should not be used in application code. :param name: The name of the decoder - :param decoder: A callable(mode, args) that returns an - ImageFile.PyDecoder object + :param decoder: An ImageFile.PyDecoder object .. versionadded:: 4.1.0 """ DECODERS[name] = decoder -def register_encoder(name, encoder): +def register_encoder(name: str, encoder: type[ImageFile.PyEncoder]) -> None: """ Registers an image encoder. This function should not be used in application code. :param name: The name of the encoder - :param encoder: A callable(mode, args) that returns an - ImageFile.PyEncoder object + :param encoder: An ImageFile.PyEncoder object .. versionadded:: 4.1.0 """ @@ -3626,7 +3648,13 @@ _apply_env_variables() atexit.register(core.clear_cache) -class Exif(MutableMapping): +if TYPE_CHECKING: + _ExifBase = MutableMapping[int, Any] +else: + _ExifBase = MutableMapping + + +class Exif(_ExifBase): """ This class provides read and write access to EXIF image data:: @@ -3662,6 +3690,7 @@ class Exif(MutableMapping): endian = None bigtiff = False + _loaded = False def __init__(self): self._data = {} @@ -3683,7 +3712,7 @@ class Exif(MutableMapping): # returns a dict with any single item tuples/lists as individual values return {k: self._fixup(v) for k, v in src_dict.items()} - def _get_ifd_dict(self, offset): + def _get_ifd_dict(self, offset, group=None): try: # an offset pointer to the location of the nested embedded IFD. # It should be a long, but may be corrupted. @@ -3693,7 +3722,7 @@ class Exif(MutableMapping): else: from . import TiffImagePlugin - info = TiffImagePlugin.ImageFileDirectory_v2(self.head) + info = TiffImagePlugin.ImageFileDirectory_v2(self.head, group=group) info.load(self.fp) return self._fixup_dict(info) @@ -3765,19 +3794,19 @@ class Exif(MutableMapping): # get EXIF extension if ExifTags.IFD.Exif in self: - ifd = self._get_ifd_dict(self[ExifTags.IFD.Exif]) + ifd = self._get_ifd_dict(self[ExifTags.IFD.Exif], ExifTags.IFD.Exif) if ifd: merged_dict.update(ifd) # GPS if ExifTags.IFD.GPSInfo in self: merged_dict[ExifTags.IFD.GPSInfo] = self._get_ifd_dict( - self[ExifTags.IFD.GPSInfo] + self[ExifTags.IFD.GPSInfo], ExifTags.IFD.GPSInfo ) return merged_dict - def tobytes(self, offset=8): + def tobytes(self, offset: int = 8) -> bytes: from . import TiffImagePlugin head = self._get_head() @@ -3806,7 +3835,7 @@ class Exif(MutableMapping): elif tag in [ExifTags.IFD.Exif, ExifTags.IFD.GPSInfo]: offset = self._hidden_data.get(tag, self.get(tag)) if offset is not None: - self._ifds[tag] = self._get_ifd_dict(offset) + self._ifds[tag] = self._get_ifd_dict(offset, tag) elif tag in [ExifTags.IFD.Interop, ExifTags.IFD.Makernote]: if ExifTags.IFD.Exif not in self._ifds: self.get_ifd(ExifTags.IFD.Exif) @@ -3888,7 +3917,7 @@ class Exif(MutableMapping): self._ifds[tag] = makernote else: # Interop - self._ifds[tag] = self._get_ifd_dict(tag_data) + self._ifds[tag] = self._get_ifd_dict(tag_data, tag) ifd = self._ifds.get(tag, {}) if tag == ExifTags.IFD.Exif and self._hidden_data: ifd = { @@ -3932,7 +3961,7 @@ class Exif(MutableMapping): del self._info[tag] self._data[tag] = value - def __delitem__(self, tag): + def __delitem__(self, tag: int) -> None: if self._info is not None and tag in self._info: del self._info[tag] else: diff --git a/src/PIL/ImageCms.py b/src/PIL/ImageCms.py index 9d27f2513..2b0ed6c9d 100644 --- a/src/PIL/ImageCms.py +++ b/src/PIL/ImageCms.py @@ -4,6 +4,9 @@ # Optional color management support, based on Kevin Cazabon's PyCMS # library. +# Originally released under LGPL. Graciously donated to PIL in +# March 2009, for distribution under the standard PIL license + # History: # 2009-03-08 fl Added to PIL. @@ -16,10 +19,14 @@ # below for the original description. from __future__ import annotations +import operator import sys -from enum import IntEnum +from enum import IntEnum, IntFlag +from functools import reduce +from typing import Any from . import Image +from ._deprecate import deprecate try: from . import _imagingcms @@ -28,9 +35,9 @@ except ImportError as ex: # anything in core. from ._util import DeferredError - _imagingcms = DeferredError(ex) + _imagingcms = DeferredError.new(ex) -DESCRIPTION = """ +_DESCRIPTION = """ pyCMS a Python / PIL interface to the littleCMS ICC Color Management System @@ -93,7 +100,22 @@ pyCMS """ -VERSION = "1.0.0 pil" +_VERSION = "1.0.0 pil" + + +def __getattr__(name: str) -> Any: + if name == "DESCRIPTION": + deprecate("PIL.ImageCms.DESCRIPTION", 12) + return _DESCRIPTION + elif name == "VERSION": + deprecate("PIL.ImageCms.VERSION", 12) + return _VERSION + elif name == "FLAGS": + deprecate("PIL.ImageCms.FLAGS", 12, "PIL.ImageCms.Flags") + return _FLAGS + msg = f"module '{__name__}' has no attribute '{name}'" + raise AttributeError(msg) + # --------------------------------------------------------------------. @@ -119,7 +141,70 @@ class Direction(IntEnum): # # flags -FLAGS = { + +class Flags(IntFlag): + """Flags and documentation are taken from ``lcms2.h``.""" + + NONE = 0 + NOCACHE = 0x0040 + """Inhibit 1-pixel cache""" + NOOPTIMIZE = 0x0100 + """Inhibit optimizations""" + NULLTRANSFORM = 0x0200 + """Don't transform anyway""" + GAMUTCHECK = 0x1000 + """Out of Gamut alarm""" + SOFTPROOFING = 0x4000 + """Do softproofing""" + BLACKPOINTCOMPENSATION = 0x2000 + NOWHITEONWHITEFIXUP = 0x0004 + """Don't fix scum dot""" + HIGHRESPRECALC = 0x0400 + """Use more memory to give better accuracy""" + LOWRESPRECALC = 0x0800 + """Use less memory to minimize resources""" + # this should be 8BITS_DEVICELINK, but that is not a valid name in Python: + USE_8BITS_DEVICELINK = 0x0008 + """Create 8 bits devicelinks""" + GUESSDEVICECLASS = 0x0020 + """Guess device class (for ``transform2devicelink``)""" + KEEP_SEQUENCE = 0x0080 + """Keep profile sequence for devicelink creation""" + FORCE_CLUT = 0x0002 + """Force CLUT optimization""" + CLUT_POST_LINEARIZATION = 0x0001 + """create postlinearization tables if possible""" + CLUT_PRE_LINEARIZATION = 0x0010 + """create prelinearization tables if possible""" + NONEGATIVES = 0x8000 + """Prevent negative numbers in floating point transforms""" + COPY_ALPHA = 0x04000000 + """Alpha channels are copied on ``cmsDoTransform()``""" + NODEFAULTRESOURCEDEF = 0x01000000 + + _GRIDPOINTS_1 = 1 << 16 + _GRIDPOINTS_2 = 2 << 16 + _GRIDPOINTS_4 = 4 << 16 + _GRIDPOINTS_8 = 8 << 16 + _GRIDPOINTS_16 = 16 << 16 + _GRIDPOINTS_32 = 32 << 16 + _GRIDPOINTS_64 = 64 << 16 + _GRIDPOINTS_128 = 128 << 16 + + @staticmethod + def GRIDPOINTS(n: int) -> Flags: + """ + Fine-tune control over number of gridpoints + + :param n: :py:class:`int` in range ``0 <= n <= 255`` + """ + return Flags.NONE | ((n & 0xFF) << 16) + + +_MAX_FLAG = reduce(operator.or_, Flags) + + +_FLAGS = { "MATRIXINPUT": 1, "MATRIXOUTPUT": 2, "MATRIXONLY": (1 | 2), @@ -142,11 +227,6 @@ FLAGS = { "GRIDPOINTS": lambda n: (n & 0xFF) << 16, # Gridpoints } -_MAX_FLAG = 0 -for flag in FLAGS.values(): - if isinstance(flag, int): - _MAX_FLAG = _MAX_FLAG | flag - # --------------------------------------------------------------------. # Experimental PIL-level API @@ -201,7 +281,6 @@ class ImageCmsProfile: class ImageCmsTransform(Image.ImagePointHandler): - """ Transform. This can be used with the procedural API, or with the standard :py:func:`~PIL.Image.Image.point` method. @@ -218,7 +297,7 @@ class ImageCmsTransform(Image.ImagePointHandler): intent=Intent.PERCEPTUAL, proof=None, proof_intent=Intent.ABSOLUTE_COLORIMETRIC, - flags=0, + flags=Flags.NONE, ): if proof is None: self.transform = core.buildTransform( @@ -289,7 +368,6 @@ def get_display_profile(handle=None): class PyCMSError(Exception): - """(pyCMS) Exception class. This is used for all errors in the pyCMS API.""" @@ -303,7 +381,7 @@ def profileToProfile( renderingIntent=Intent.PERCEPTUAL, outputMode=None, inPlace=False, - flags=0, + flags=Flags.NONE, ): """ (pyCMS) Applies an ICC transformation to a given image, mapping from @@ -420,7 +498,7 @@ def buildTransform( inMode, outMode, renderingIntent=Intent.PERCEPTUAL, - flags=0, + flags=Flags.NONE, ): """ (pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the @@ -482,7 +560,7 @@ def buildTransform( raise PyCMSError(msg) if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG): - msg = "flags must be an integer between 0 and %s" + _MAX_FLAG + msg = f"flags must be an integer between 0 and {_MAX_FLAG}" raise PyCMSError(msg) try: @@ -505,7 +583,7 @@ def buildProofTransform( outMode, renderingIntent=Intent.PERCEPTUAL, proofRenderingIntent=Intent.ABSOLUTE_COLORIMETRIC, - flags=FLAGS["SOFTPROOFING"], + flags=Flags.SOFTPROOFING, ): """ (pyCMS) Builds an ICC transform mapping from the ``inputProfile`` to the @@ -586,7 +664,7 @@ def buildProofTransform( raise PyCMSError(msg) if not isinstance(flags, int) or not (0 <= flags <= _MAX_FLAG): - msg = "flags must be an integer between 0 and %s" + _MAX_FLAG + msg = f"flags must be an integer between 0 and {_MAX_FLAG}" raise PyCMSError(msg) try: @@ -1004,4 +1082,9 @@ def versions(): (pyCMS) Fetches versions. """ - return VERSION, core.littlecms_version, sys.version.split()[0], Image.__version__ + deprecate( + "PIL.ImageCms.versions()", + 12, + '(PIL.features.version("littlecms2"), sys.version, PIL.__version__)', + ) + return _VERSION, core.littlecms_version, sys.version.split()[0], Image.__version__ diff --git a/src/PIL/ImageColor.py b/src/PIL/ImageColor.py index bfad27c82..5fb80b753 100644 --- a/src/PIL/ImageColor.py +++ b/src/PIL/ImageColor.py @@ -19,10 +19,12 @@ from __future__ import annotations import re +from functools import lru_cache from . import Image +@lru_cache def getrgb(color): """ Convert a color string to an RGB or RGBA tuple. If the string cannot be @@ -121,7 +123,8 @@ def getrgb(color): raise ValueError(msg) -def getcolor(color, mode): +@lru_cache +def getcolor(color, mode: str) -> tuple[int, ...]: """ Same as :py:func:`~PIL.ImageColor.getrgb` for most modes. However, if ``mode`` is HSV, converts the RGB value to a HSV value, or if ``mode`` is diff --git a/src/PIL/ImageDraw.py b/src/PIL/ImageDraw.py index 84665f54f..d4e000087 100644 --- a/src/PIL/ImageDraw.py +++ b/src/PIL/ImageDraw.py @@ -34,8 +34,10 @@ from __future__ import annotations import math import numbers import struct +from typing import Sequence, cast from . import Image, ImageColor +from ._typing import Coords """ A simple 2D drawing interface for PIL images. @@ -48,7 +50,7 @@ directly. class ImageDraw: font = None - def __init__(self, im, mode=None): + def __init__(self, im: Image.Image, mode: str | None = None) -> None: """ Create a drawing instance. @@ -115,7 +117,7 @@ class ImageDraw: self.font = ImageFont.load_default() return self.font - def _getfont(self, font_size): + def _getfont(self, font_size: float | None): if font_size is not None: from . import ImageFont @@ -124,7 +126,7 @@ class ImageDraw: font = self.getfont() return font - def _getink(self, ink, fill=None): + def _getink(self, ink, fill=None) -> tuple[int | None, int | None]: if ink is None and fill is None: if self.fill: fill = self.ink @@ -145,13 +147,13 @@ class ImageDraw: fill = self.draw.draw_ink(fill) return ink, fill - def arc(self, xy, start, end, fill=None, width=1): + def arc(self, xy: Coords, start, end, fill=None, width=1) -> None: """Draw an arc.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_arc(xy, start, end, ink, width) - def bitmap(self, xy, bitmap, fill=None): + def bitmap(self, xy: Sequence[int], bitmap, fill=None) -> None: """Draw a bitmap.""" bitmap.load() ink, fill = self._getink(fill) @@ -160,7 +162,7 @@ class ImageDraw: if ink is not None: self.draw.draw_bitmap(xy, bitmap.im, ink) - def chord(self, xy, start, end, fill=None, outline=None, width=1): + def chord(self, xy: Coords, start, end, fill=None, outline=None, width=1) -> None: """Draw a chord.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -168,7 +170,7 @@ class ImageDraw: if ink is not None and ink != fill and width != 0: self.draw.draw_chord(xy, start, end, ink, 0, width) - def ellipse(self, xy, fill=None, outline=None, width=1): + def ellipse(self, xy: Coords, fill=None, outline=None, width=1) -> None: """Draw an ellipse.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -176,20 +178,29 @@ class ImageDraw: if ink is not None and ink != fill and width != 0: self.draw.draw_ellipse(xy, ink, 0, width) - def line(self, xy, fill=None, width=0, joint=None): + def line(self, xy: Coords, fill=None, width=0, joint=None) -> None: """Draw a line, or a connected sequence of line segments.""" ink = self._getink(fill)[0] if ink is not None: self.draw.draw_lines(xy, ink, width) if joint == "curve" and width > 4: - if not isinstance(xy[0], (list, tuple)): - xy = [tuple(xy[i : i + 2]) for i in range(0, len(xy), 2)] - for i in range(1, len(xy) - 1): - point = xy[i] + points: Sequence[Sequence[float]] + if isinstance(xy[0], (list, tuple)): + points = cast(Sequence[Sequence[float]], xy) + else: + points = [ + cast(Sequence[float], tuple(xy[i : i + 2])) + for i in range(0, len(xy), 2) + ] + for i in range(1, len(points) - 1): + point = points[i] angles = [ math.degrees(math.atan2(end[0] - start[0], start[1] - end[1])) % 360 - for start, end in ((xy[i - 1], point), (point, xy[i + 1])) + for start, end in ( + (points[i - 1], point), + (point, points[i + 1]), + ) ] if angles[0] == angles[1]: # This is a straight line, so no joint is required @@ -236,7 +247,7 @@ class ImageDraw: ] self.line(gap_coords, fill, width=3) - def shape(self, shape, fill=None, outline=None): + def shape(self, shape, fill=None, outline=None) -> None: """(Experimental) Draw a shape.""" shape.close() ink, fill = self._getink(outline, fill) @@ -245,7 +256,9 @@ class ImageDraw: if ink is not None and ink != fill: self.draw.draw_outline(shape, ink, 0) - def pieslice(self, xy, start, end, fill=None, outline=None, width=1): + def pieslice( + self, xy: Coords, start, end, fill=None, outline=None, width=1 + ) -> None: """Draw a pieslice.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -253,13 +266,13 @@ class ImageDraw: if ink is not None and ink != fill and width != 0: self.draw.draw_pieslice(xy, start, end, ink, 0, width) - def point(self, xy, fill=None): + def point(self, xy: Coords, fill=None) -> None: """Draw one or more individual pixels.""" ink, fill = self._getink(fill) if ink is not None: self.draw.draw_points(xy, ink) - def polygon(self, xy, fill=None, outline=None, width=1): + def polygon(self, xy: Coords, fill=None, outline=None, width=1) -> None: """Draw a polygon.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -267,7 +280,7 @@ class ImageDraw: if ink is not None and ink != fill and width != 0: if width == 1: self.draw.draw_polygon(xy, ink, 0, width) - else: + elif self.im is not None: # To avoid expanding the polygon outwards, # use the fill as a mask mask = Image.new("1", self.im.size) @@ -291,12 +304,12 @@ class ImageDraw: def regular_polygon( self, bounding_circle, n_sides, rotation=0, fill=None, outline=None, width=1 - ): + ) -> None: """Draw a regular polygon.""" xy = _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation) self.polygon(xy, fill, outline, width) - def rectangle(self, xy, fill=None, outline=None, width=1): + def rectangle(self, xy: Coords, fill=None, outline=None, width=1) -> None: """Draw a rectangle.""" ink, fill = self._getink(outline, fill) if fill is not None: @@ -305,13 +318,13 @@ class ImageDraw: self.draw.draw_rectangle(xy, ink, 0, width) def rounded_rectangle( - self, xy, radius=0, fill=None, outline=None, width=1, *, corners=None - ): + self, xy: Coords, radius=0, fill=None, outline=None, width=1, *, corners=None + ) -> None: """Draw a rounded rectangle.""" if isinstance(xy[0], (list, tuple)): - (x0, y0), (x1, y1) = xy + (x0, y0), (x1, y1) = cast(Sequence[Sequence[float]], xy) else: - x0, y0, x1, y1 = xy + x0, y0, x1, y1 = cast(Sequence[float], xy) if x1 < x0: msg = "x1 must be greater than or equal to x0" raise ValueError(msg) @@ -346,7 +359,8 @@ class ImageDraw: r = d // 2 ink, fill = self._getink(outline, fill) - def draw_corners(pieslice): + def draw_corners(pieslice) -> None: + parts: tuple[tuple[tuple[float, float, float, float], int, int], ...] if full_x: # Draw top and bottom halves parts = ( @@ -361,17 +375,18 @@ class ImageDraw: ) else: # Draw four separate corners - parts = [] - for i, part in enumerate( - ( - ((x0, y0, x0 + d, y0 + d), 180, 270), - ((x1 - d, y0, x1, y0 + d), 270, 360), - ((x1 - d, y1 - d, x1, y1), 0, 90), - ((x0, y1 - d, x0 + d, y1), 90, 180), + parts = tuple( + part + for i, part in enumerate( + ( + ((x0, y0, x0 + d, y0 + d), 180, 270), + ((x1 - d, y0, x1, y0 + d), 270, 360), + ((x1 - d, y1 - d, x1, y1), 0, 90), + ((x0, y1 - d, x0 + d, y1), 90, 180), + ) ) - ): - if corners[i]: - parts.append(part) + if corners[i] + ) for part in parts: if pieslice: self.draw.draw_pieslice(*(part + (fill, 1))) @@ -431,12 +446,12 @@ class ImageDraw: right[3] -= r + 1 self.draw.draw_rectangle(right, ink, 1) - def _multiline_check(self, text): + def _multiline_check(self, text) -> bool: split_character = "\n" if isinstance(text, str) else b"\n" return split_character in text - def _multiline_split(self, text): + def _multiline_split(self, text) -> list[str | bytes]: split_character = "\n" if isinstance(text, str) else b"\n" return text.split(split_character) @@ -465,7 +480,7 @@ class ImageDraw: embedded_color=False, *args, **kwargs, - ): + ) -> None: """Draw text.""" if embedded_color and self.mode not in ("RGB", "RGBA"): msg = "Embedded color supported only in RGB and RGBA modes" @@ -497,7 +512,7 @@ class ImageDraw: return fill return ink - def draw_text(ink, stroke_width=0, stroke_offset=None): + def draw_text(ink, stroke_width=0, stroke_offset=None) -> None: mode = self.fontmode if stroke_width == 0 and embedded_color: mode = "RGBA" @@ -520,7 +535,7 @@ class ImageDraw: *args, **kwargs, ) - coord = coord[0] + offset[0], coord[1] + offset[1] + coord = [coord[0] + offset[0], coord[1] + offset[1]] except AttributeError: try: mask = font.getmask( @@ -539,7 +554,7 @@ class ImageDraw: except TypeError: mask = font.getmask(text) if stroke_offset: - coord = coord[0] + stroke_offset[0], coord[1] + stroke_offset[1] + coord = [coord[0] + stroke_offset[0], coord[1] + stroke_offset[1]] if mode == "RGBA": # font.getmask2(mode="RGBA") returns color in RGB bands and mask in A # extract mask and set text alpha @@ -547,7 +562,10 @@ class ImageDraw: ink_alpha = struct.pack("i", ink)[3] color.fillband(3, ink_alpha) x, y = coord - self.im.paste(color, (x, y, x + mask.size[0], y + mask.size[1]), mask) + if self.im is not None: + self.im.paste( + color, (x, y, x + mask.size[0], y + mask.size[1]), mask + ) else: self.draw.draw_bitmap(coord, mask, ink) @@ -584,7 +602,7 @@ class ImageDraw: embedded_color=False, *, font_size=None, - ): + ) -> None: if direction == "ttb": msg = "ttb direction is unsupported for multiline text" raise ValueError(msg) @@ -693,7 +711,7 @@ class ImageDraw: embedded_color=False, *, font_size=None, - ): + ) -> tuple[int, int, int, int]: """Get the bounding box of a given string, in pixels.""" if embedded_color and self.mode not in ("RGB", "RGBA"): msg = "Embedded color supported only in RGB and RGBA modes" @@ -738,7 +756,7 @@ class ImageDraw: embedded_color=False, *, font_size=None, - ): + ) -> tuple[int, int, int, int]: if direction == "ttb": msg = "ttb direction is unsupported for multiline text" raise ValueError(msg) @@ -777,7 +795,7 @@ class ImageDraw: elif anchor[1] == "d": top -= (len(lines) - 1) * line_spacing - bbox = None + bbox: tuple[int, int, int, int] | None = None for idx, line in enumerate(lines): left = xy[0] @@ -828,7 +846,7 @@ class ImageDraw: return bbox -def Draw(im, mode=None): +def Draw(im, mode: str | None = None) -> ImageDraw: """ A simple 2D drawing interface for PIL images. @@ -876,7 +894,7 @@ def getdraw(im=None, hints=None): return im, handler -def floodfill(image, xy, value, border=None, thresh=0): +def floodfill(image: Image.Image, xy, value, border=None, thresh=0) -> None: """ (experimental) Fills a bounded region with a given color. @@ -932,7 +950,9 @@ def floodfill(image, xy, value, border=None, thresh=0): edge = new_edge -def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): +def _compute_regular_polygon_vertices( + bounding_circle, n_sides, rotation +) -> list[tuple[float, float]]: """ Generate a list of vertices for a 2D regular polygon. @@ -982,7 +1002,7 @@ def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): # 1.2 Check `bounding_circle` has an appropriate value if not isinstance(bounding_circle, (list, tuple)): - msg = "bounding_circle should be a tuple" + msg = "bounding_circle should be a sequence" raise TypeError(msg) if len(bounding_circle) == 3: @@ -1014,7 +1034,7 @@ def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): raise ValueError(msg) # 2. Define Helper Functions - def _apply_rotation(point, degrees, centroid): + def _apply_rotation(point: list[float], degrees: float) -> tuple[int, int]: return ( round( point[0] * math.cos(math.radians(360 - degrees)) @@ -1030,11 +1050,11 @@ def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): ), ) - def _compute_polygon_vertex(centroid, polygon_radius, angle): + def _compute_polygon_vertex(angle: float) -> tuple[int, int]: start_point = [polygon_radius, 0] - return _apply_rotation(start_point, angle, centroid) + return _apply_rotation(start_point, angle) - def _get_angles(n_sides, rotation): + def _get_angles(n_sides: int, rotation: float) -> list[float]: angles = [] degrees = 360 / n_sides # Start with the bottom left polygon vertex @@ -1050,12 +1070,10 @@ def _compute_regular_polygon_vertices(bounding_circle, n_sides, rotation): angles = _get_angles(n_sides, rotation) # 4. Compute Vertices - return [ - _compute_polygon_vertex(centroid, polygon_radius, angle) for angle in angles - ] + return [_compute_polygon_vertex(angle) for angle in angles] -def _color_diff(color1, color2): +def _color_diff(color1, color2: float | tuple[int, ...]) -> float: """ Uses 1-norm distance to calculate difference between two values. """ diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index ae4e23db1..0283fa2fd 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -32,7 +32,7 @@ import io import itertools import struct import sys -from typing import NamedTuple +from typing import IO, Any, NamedTuple from . import Image from ._deprecate import deprecate @@ -91,10 +91,10 @@ def _tilesort(t): class _Tile(NamedTuple): - encoder_name: str + codec_name: str extents: tuple[int, int, int, int] offset: int - args: tuple | str | None + args: tuple[Any, ...] | str | None # @@ -328,7 +328,7 @@ class ImageFile(Image.Image): # pass # may be defined for blocked formats (e.g. PNG) - # def load_read(self, bytes): + # def load_read(self, read_bytes): # pass def _seek_check(self, frame): @@ -384,7 +384,7 @@ class Parser: """ incremental = None - image = None + image: Image.Image | None = None data = None decoder = None offset = 0 @@ -514,7 +514,7 @@ class Parser: # -------------------------------------------------------------------- -def _save(im, fp, tile, bufsize=0): +def _save(im, fp, tile, bufsize=0) -> None: """Helper to save image based on tile list :param im: Image object. @@ -616,6 +616,8 @@ class PyCodecState: class PyCodec: + fd: IO[bytes] | None + def __init__(self, mode, *args): self.im = None self.state = PyCodecState() @@ -713,7 +715,7 @@ class PyDecoder(PyCodec): msg = "unavailable in base decoder" raise NotImplementedError(msg) - def set_as_raw(self, data, rawmode=None): + def set_as_raw(self, data: bytes, rawmode=None) -> None: """ Convenience method to set the internal image from a stream of raw data diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py index 021b40c0e..035b83c4d 100644 --- a/src/PIL/ImageFilter.py +++ b/src/PIL/ImageFilter.py @@ -396,7 +396,7 @@ class Color3DLUT(MultibandFilter): if hasattr(table, "shape"): try: import numpy - except ImportError: # pragma: no cover + except ImportError: pass if numpy and isinstance(table, numpy.ndarray): diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 6db7cc4ec..256c581df 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -33,10 +33,10 @@ import sys import warnings from enum import IntEnum from io import BytesIO -from pathlib import Path -from typing import IO +from typing import BinaryIO from . import Image +from ._typing import StrOrBytesPath from ._util import is_directory, is_path @@ -53,7 +53,7 @@ try: except ImportError as ex: from ._util import DeferredError - core = DeferredError(ex) + core = DeferredError.new(ex) def _string_length_check(text): @@ -149,6 +149,8 @@ class ImageFont: :return: An internal PIL storage memory instance as defined by the :py:mod:`PIL.Image.core` interface module. """ + _string_length_check(text) + Image._decompression_bomb_check(self.font.getsize(text)) return self.font.getmask(text, mode) def getbbox(self, text, *args, **kwargs): @@ -191,7 +193,7 @@ class FreeTypeFont: def __init__( self, - font: bytes | str | Path | IO | None = None, + font: StrOrBytesPath | BinaryIO | None = None, size: float = 10, index: int = 0, encoding: str = "", @@ -228,8 +230,7 @@ class FreeTypeFont: ) if is_path(font): - if isinstance(font, Path): - font = str(font) + font = os.path.realpath(os.fspath(font)) if sys.platform == "win32": font_bytes_path = font if isinstance(font, bytes) else font.encode() try: @@ -582,22 +583,13 @@ class FreeTypeFont: _string_length_check(text) if start is None: start = (0, 0) - im = None - size = None def fill(width, height): - nonlocal im, size - size = (width, height) - if Image.MAX_IMAGE_PIXELS is not None: - pixels = max(1, width) * max(1, height) - if pixels > 2 * Image.MAX_IMAGE_PIXELS: - return + Image._decompression_bomb_check(size) + return Image.core.fill("RGBA" if mode == "RGBA" else "L", size) - im = Image.core.fill("RGBA" if mode == "RGBA" else "L", size) - return im - - offset = self.font.render( + return self.font.render( text, fill, mode, @@ -610,8 +602,6 @@ class FreeTypeFont: start[0], start[1], ) - Image._decompression_bomb_check(size) - return im, offset def font_variant( self, font=None, size=None, index=None, encoding=None, layout_engine=None @@ -881,7 +871,7 @@ def load_path(filename): raise OSError(msg) -def load_default(size=None): +def load_default(size: float | None = None) -> FreeTypeFont | ImageFont: """If FreeType support is available, load a version of Aileron Regular, https://dotcolon.net/font/aileron, with a more limited character set. diff --git a/src/PIL/ImageGrab.py b/src/PIL/ImageGrab.py index a4993d3d4..3f3be706d 100644 --- a/src/PIL/ImageGrab.py +++ b/src/PIL/ImageGrab.py @@ -149,18 +149,7 @@ def grabclipboard(): session_type = None if shutil.which("wl-paste") and session_type in ("wayland", None): - output = subprocess.check_output(["wl-paste", "-l"]).decode() - mimetypes = output.splitlines() - if "image/png" in mimetypes: - mimetype = "image/png" - elif mimetypes: - mimetype = mimetypes[0] - else: - mimetype = None - - args = ["wl-paste"] - if mimetype: - args.extend(["-t", mimetype]) + args = ["wl-paste", "-t", "image"] elif shutil.which("xclip") and session_type in ("x11", None): args = ["xclip", "-selection", "clipboard", "-t", "image/png", "-o"] else: @@ -168,10 +157,29 @@ def grabclipboard(): raise NotImplementedError(msg) p = subprocess.run(args, capture_output=True) - err = p.stderr - if err: - msg = f"{args[0]} error: {err.strip().decode()}" + if p.returncode != 0: + err = p.stderr + for silent_error in [ + # wl-paste, when the clipboard is empty + b"Nothing is copied", + # Ubuntu/Debian wl-paste, when the clipboard is empty + b"No selection", + # Ubuntu/Debian wl-paste, when an image isn't available + b"No suitable type of content copied", + # wl-paste or Ubuntu/Debian xclip, when an image isn't available + b" not available", + # xclip, when an image isn't available + b"cannot convert ", + # xclip, when the clipboard isn't initialized + b"xclip: Error: There is no owner for the ", + ]: + if silent_error in err: + return None + msg = f"{args[0]} error" + if err: + msg += f": {err.strip().decode()}" raise ChildProcessError(msg) + data = io.BytesIO(p.stdout) im = Image.open(data) im.load() diff --git a/src/PIL/ImageMath.py b/src/PIL/ImageMath.py index 7ca512e75..a7652f237 100644 --- a/src/PIL/ImageMath.py +++ b/src/PIL/ImageMath.py @@ -17,6 +17,8 @@ from __future__ import annotations import builtins +from types import CodeType +from typing import Any from . import Image, _imagingmath @@ -24,10 +26,10 @@ from . import Image, _imagingmath class _Operand: """Wraps an image operand, providing standard operators""" - def __init__(self, im): + def __init__(self, im: Image.Image): self.im = im - def __fixup(self, im1): + def __fixup(self, im1: _Operand | float) -> Image.Image: # convert image to suitable mode if isinstance(im1, _Operand): # argument was an image. @@ -45,122 +47,131 @@ class _Operand: else: return Image.new("F", self.im.size, im1) - def apply(self, op, im1, im2=None, mode=None): - im1 = self.__fixup(im1) + def apply( + self, + op: str, + im1: _Operand | float, + im2: _Operand | float | None = None, + mode: str | None = None, + ) -> _Operand: + im_1 = self.__fixup(im1) if im2 is None: # unary operation - out = Image.new(mode or im1.mode, im1.size, None) - im1.load() + out = Image.new(mode or im_1.mode, im_1.size, None) + im_1.load() try: - op = getattr(_imagingmath, op + "_" + im1.mode) + op = getattr(_imagingmath, op + "_" + im_1.mode) except AttributeError as e: msg = f"bad operand type for '{op}'" raise TypeError(msg) from e - _imagingmath.unop(op, out.im.id, im1.im.id) + _imagingmath.unop(op, out.im.id, im_1.im.id) else: # binary operation - im2 = self.__fixup(im2) - if im1.mode != im2.mode: + im_2 = self.__fixup(im2) + if im_1.mode != im_2.mode: # convert both arguments to floating point - if im1.mode != "F": - im1 = im1.convert("F") - if im2.mode != "F": - im2 = im2.convert("F") - if im1.size != im2.size: + if im_1.mode != "F": + im_1 = im_1.convert("F") + if im_2.mode != "F": + im_2 = im_2.convert("F") + if im_1.size != im_2.size: # crop both arguments to a common size - size = (min(im1.size[0], im2.size[0]), min(im1.size[1], im2.size[1])) - if im1.size != size: - im1 = im1.crop((0, 0) + size) - if im2.size != size: - im2 = im2.crop((0, 0) + size) - out = Image.new(mode or im1.mode, im1.size, None) - im1.load() - im2.load() + size = ( + min(im_1.size[0], im_2.size[0]), + min(im_1.size[1], im_2.size[1]), + ) + if im_1.size != size: + im_1 = im_1.crop((0, 0) + size) + if im_2.size != size: + im_2 = im_2.crop((0, 0) + size) + out = Image.new(mode or im_1.mode, im_1.size, None) + im_1.load() + im_2.load() try: - op = getattr(_imagingmath, op + "_" + im1.mode) + op = getattr(_imagingmath, op + "_" + im_1.mode) except AttributeError as e: msg = f"bad operand type for '{op}'" raise TypeError(msg) from e - _imagingmath.binop(op, out.im.id, im1.im.id, im2.im.id) + _imagingmath.binop(op, out.im.id, im_1.im.id, im_2.im.id) return _Operand(out) # unary operators - def __bool__(self): + def __bool__(self) -> bool: # an image is "true" if it contains at least one non-zero pixel return self.im.getbbox() is not None - def __abs__(self): + def __abs__(self) -> _Operand: return self.apply("abs", self) - def __pos__(self): + def __pos__(self) -> _Operand: return self - def __neg__(self): + def __neg__(self) -> _Operand: return self.apply("neg", self) # binary operators - def __add__(self, other): + def __add__(self, other: _Operand | float) -> _Operand: return self.apply("add", self, other) - def __radd__(self, other): + def __radd__(self, other: _Operand | float) -> _Operand: return self.apply("add", other, self) - def __sub__(self, other): + def __sub__(self, other: _Operand | float) -> _Operand: return self.apply("sub", self, other) - def __rsub__(self, other): + def __rsub__(self, other: _Operand | float) -> _Operand: return self.apply("sub", other, self) - def __mul__(self, other): + def __mul__(self, other: _Operand | float) -> _Operand: return self.apply("mul", self, other) - def __rmul__(self, other): + def __rmul__(self, other: _Operand | float) -> _Operand: return self.apply("mul", other, self) - def __truediv__(self, other): + def __truediv__(self, other: _Operand | float) -> _Operand: return self.apply("div", self, other) - def __rtruediv__(self, other): + def __rtruediv__(self, other: _Operand | float) -> _Operand: return self.apply("div", other, self) - def __mod__(self, other): + def __mod__(self, other: _Operand | float) -> _Operand: return self.apply("mod", self, other) - def __rmod__(self, other): + def __rmod__(self, other: _Operand | float) -> _Operand: return self.apply("mod", other, self) - def __pow__(self, other): + def __pow__(self, other: _Operand | float) -> _Operand: return self.apply("pow", self, other) - def __rpow__(self, other): + def __rpow__(self, other: _Operand | float) -> _Operand: return self.apply("pow", other, self) # bitwise - def __invert__(self): + def __invert__(self) -> _Operand: return self.apply("invert", self) - def __and__(self, other): + def __and__(self, other: _Operand | float) -> _Operand: return self.apply("and", self, other) - def __rand__(self, other): + def __rand__(self, other: _Operand | float) -> _Operand: return self.apply("and", other, self) - def __or__(self, other): + def __or__(self, other: _Operand | float) -> _Operand: return self.apply("or", self, other) - def __ror__(self, other): + def __ror__(self, other: _Operand | float) -> _Operand: return self.apply("or", other, self) - def __xor__(self, other): + def __xor__(self, other: _Operand | float) -> _Operand: return self.apply("xor", self, other) - def __rxor__(self, other): + def __rxor__(self, other: _Operand | float) -> _Operand: return self.apply("xor", other, self) - def __lshift__(self, other): + def __lshift__(self, other: _Operand | float) -> _Operand: return self.apply("lshift", self, other) - def __rshift__(self, other): + def __rshift__(self, other: _Operand | float) -> _Operand: return self.apply("rshift", self, other) # logical @@ -170,56 +181,61 @@ class _Operand: def __ne__(self, other): return self.apply("ne", self, other) - def __lt__(self, other): + def __lt__(self, other: _Operand | float) -> _Operand: return self.apply("lt", self, other) - def __le__(self, other): + def __le__(self, other: _Operand | float) -> _Operand: return self.apply("le", self, other) - def __gt__(self, other): + def __gt__(self, other: _Operand | float) -> _Operand: return self.apply("gt", self, other) - def __ge__(self, other): + def __ge__(self, other: _Operand | float) -> _Operand: return self.apply("ge", self, other) # conversions -def imagemath_int(self): +def imagemath_int(self: _Operand) -> _Operand: return _Operand(self.im.convert("I")) -def imagemath_float(self): +def imagemath_float(self: _Operand) -> _Operand: return _Operand(self.im.convert("F")) # logical -def imagemath_equal(self, other): +def imagemath_equal(self: _Operand, other: _Operand | float | None) -> _Operand: return self.apply("eq", self, other, mode="I") -def imagemath_notequal(self, other): +def imagemath_notequal(self: _Operand, other: _Operand | float | None) -> _Operand: return self.apply("ne", self, other, mode="I") -def imagemath_min(self, other): +def imagemath_min(self: _Operand, other: _Operand | float | None) -> _Operand: return self.apply("min", self, other) -def imagemath_max(self, other): +def imagemath_max(self: _Operand, other: _Operand | float | None) -> _Operand: return self.apply("max", self, other) -def imagemath_convert(self, mode): +def imagemath_convert(self: _Operand, mode: str) -> _Operand: return _Operand(self.im.convert(mode)) -ops = {} -for k, v in list(globals().items()): - if k[:10] == "imagemath_": - ops[k[10:]] = v +ops = { + "int": imagemath_int, + "float": imagemath_float, + "equal": imagemath_equal, + "notequal": imagemath_notequal, + "min": imagemath_min, + "max": imagemath_max, + "convert": imagemath_convert, +} -def eval(expression, _dict={}, **kw): +def eval(expression: str, _dict: dict[str, Any] = {}, **kw: Any) -> Any: """ Evaluates an image expression. @@ -233,7 +249,12 @@ def eval(expression, _dict={}, **kw): """ # build execution namespace - args = ops.copy() + args: dict[str, Any] = ops.copy() + for k in list(_dict.keys()) + list(kw.keys()): + if "__" in k or hasattr(builtins, k): + msg = f"'{k}' not allowed" + raise ValueError(msg) + args.update(_dict) args.update(kw) for k, v in args.items(): @@ -242,7 +263,7 @@ def eval(expression, _dict={}, **kw): compiled_code = compile(expression, "", "eval") - def scan(code): + def scan(code: CodeType) -> None: for const in code.co_consts: if type(const) is type(compiled_code): scan(const) diff --git a/src/PIL/ImageMorph.py b/src/PIL/ImageMorph.py index 282e7d2a5..534c6291a 100644 --- a/src/PIL/ImageMorph.py +++ b/src/PIL/ImageMorph.py @@ -62,12 +62,14 @@ class LutBuilder: """ - def __init__(self, patterns=None, op_name=None): + def __init__( + self, patterns: list[str] | None = None, op_name: str | None = None + ) -> None: if patterns is not None: self.patterns = patterns else: self.patterns = [] - self.lut = None + self.lut: bytearray | None = None if op_name is not None: known_patterns = { "corner": ["1:(... ... ...)->0", "4:(00. 01. ...)->1"], @@ -87,25 +89,27 @@ class LutBuilder: self.patterns = known_patterns[op_name] - def add_patterns(self, patterns): + def add_patterns(self, patterns: list[str]) -> None: self.patterns += patterns - def build_default_lut(self): + def build_default_lut(self) -> None: symbols = [0, 1] m = 1 << 4 # pos of current pixel self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE)) - def get_lut(self): + def get_lut(self) -> bytearray | None: return self.lut - def _string_permute(self, pattern, permutation): + def _string_permute(self, pattern: str, permutation: list[int]) -> str: """string_permute takes a pattern and a permutation and returns the string permuted according to the permutation list. """ assert len(permutation) == 9 return "".join(pattern[p] for p in permutation) - def _pattern_permute(self, basic_pattern, options, basic_result): + def _pattern_permute( + self, basic_pattern: str, options: str, basic_result: int + ) -> list[tuple[str, int]]: """pattern_permute takes a basic pattern and its result and clones the pattern according to the modifications described in the $options parameter. It returns a list of all cloned patterns.""" @@ -135,12 +139,13 @@ class LutBuilder: return patterns - def build_lut(self): + def build_lut(self) -> bytearray: """Compile all patterns into a morphology lut. TBD :Build based on (file) morphlut:modify_lut """ self.build_default_lut() + assert self.lut is not None patterns = [] # Parse and create symmetries of the patterns strings @@ -159,10 +164,10 @@ class LutBuilder: patterns += self._pattern_permute(pattern, options, result) # compile the patterns into regular expressions for speed - for i, pattern in enumerate(patterns): + compiled_patterns = [] + for pattern in patterns: p = pattern[0].replace(".", "X").replace("X", "[01]") - p = re.compile(p) - patterns[i] = (p, pattern[1]) + compiled_patterns.append((re.compile(p), pattern[1])) # Step through table and find patterns that match. # Note that all the patterns are searched. The last one @@ -172,8 +177,8 @@ class LutBuilder: bitpattern = bin(i)[2:] bitpattern = ("0" * (9 - len(bitpattern)) + bitpattern)[::-1] - for p, r in patterns: - if p.match(bitpattern): + for pattern, r in compiled_patterns: + if pattern.match(bitpattern): self.lut[i] = [0, 1][r] return self.lut @@ -182,7 +187,12 @@ class LutBuilder: class MorphOp: """A class for binary morphological operators""" - def __init__(self, lut=None, op_name=None, patterns=None): + def __init__( + self, + lut: bytearray | None = None, + op_name: str | None = None, + patterns: list[str] | None = None, + ) -> None: """Create a binary morphological operator""" self.lut = lut if op_name is not None: @@ -190,7 +200,7 @@ class MorphOp: elif patterns is not None: self.lut = LutBuilder(patterns=patterns).build_lut() - def apply(self, image): + def apply(self, image: Image.Image): """Run a single morphological operation on an image Returns a tuple of the number of changed pixels and the @@ -206,7 +216,7 @@ class MorphOp: count = _imagingmorph.apply(bytes(self.lut), image.im.id, outimage.im.id) return count, outimage - def match(self, image): + def match(self, image: Image.Image): """Get a list of coordinates matching the morphological operation on an image. @@ -221,7 +231,7 @@ class MorphOp: raise ValueError(msg) return _imagingmorph.match(bytes(self.lut), image.im.id) - def get_on_pixels(self, image): + def get_on_pixels(self, image: Image.Image): """Get a list of all turned on pixels in a binary image Returns a list of tuples of (x,y) coordinates @@ -232,7 +242,7 @@ class MorphOp: raise ValueError(msg) return _imagingmorph.get_on_pixels(image.im.id) - def load_lut(self, filename): + def load_lut(self, filename: str) -> None: """Load an operator from an mrl file""" with open(filename, "rb") as f: self.lut = bytearray(f.read()) @@ -242,7 +252,7 @@ class MorphOp: msg = "Wrong size operator file!" raise Exception(msg) - def save_lut(self, filename): + def save_lut(self, filename: str) -> None: """Save an operator to an mrl file""" if self.lut is None: msg = "No operator loaded" @@ -250,6 +260,6 @@ class MorphOp: with open(filename, "wb") as f: f.write(self.lut) - def set_lut(self, lut): + def set_lut(self, lut: bytearray | None) -> None: """Set the lut from an external source""" self.lut = lut diff --git a/src/PIL/ImageOps.py b/src/PIL/ImageOps.py index a9e626b2b..33db8fa50 100644 --- a/src/PIL/ImageOps.py +++ b/src/PIL/ImageOps.py @@ -21,6 +21,7 @@ from __future__ import annotations import functools import operator import re +from typing import Protocol, Sequence, cast from . import ExifTags, Image, ImagePalette @@ -28,7 +29,7 @@ from . import ExifTags, Image, ImagePalette # helpers -def _border(border): +def _border(border: int | tuple[int, ...]) -> tuple[int, int, int, int]: if isinstance(border, tuple): if len(border) == 2: left, top = right, bottom = border @@ -39,7 +40,7 @@ def _border(border): return left, top, right, bottom -def _color(color, mode): +def _color(color: str | int | tuple[int, ...], mode: str) -> int | tuple[int, ...]: if isinstance(color, str): from . import ImageColor @@ -47,7 +48,7 @@ def _color(color, mode): return color -def _lut(image, lut): +def _lut(image: Image.Image, lut: list[int]) -> Image.Image: if image.mode == "P": # FIXME: apply to lookup table, not image data msg = "mode P support coming soon" @@ -65,7 +66,13 @@ def _lut(image, lut): # actions -def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): +def autocontrast( + image: Image.Image, + cutoff: float | tuple[float, float] = 0, + ignore: int | Sequence[int] | None = None, + mask: Image.Image | None = None, + preserve_tone: bool = False, +) -> Image.Image: """ Maximize (normalize) image contrast. This function calculates a histogram of the input image (or mask region), removes ``cutoff`` percent of the @@ -97,10 +104,9 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): h = histogram[layer : layer + 256] if ignore is not None: # get rid of outliers - try: + if isinstance(ignore, int): h[ignore] = 0 - except TypeError: - # assume sequence + else: for ix in ignore: h[ix] = 0 if cutoff: @@ -112,7 +118,7 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): for ix in range(256): n = n + h[ix] # remove cutoff% pixels from the low end - cut = n * cutoff[0] // 100 + cut = int(n * cutoff[0] // 100) for lo in range(256): if cut > h[lo]: cut = cut - h[lo] @@ -123,7 +129,7 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): if cut <= 0: break # remove cutoff% samples from the high end - cut = n * cutoff[1] // 100 + cut = int(n * cutoff[1] // 100) for hi in range(255, -1, -1): if cut > h[hi]: cut = cut - h[hi] @@ -156,7 +162,15 @@ def autocontrast(image, cutoff=0, ignore=None, mask=None, preserve_tone=False): return _lut(image, lut) -def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoint=127): +def colorize( + image: Image.Image, + black: str | tuple[int, ...], + white: str | tuple[int, ...], + mid: str | int | tuple[int, ...] | None = None, + blackpoint: int = 0, + whitepoint: int = 255, + midpoint: int = 127, +) -> Image.Image: """ Colorize grayscale image. This function calculates a color wedge which maps all black pixels in @@ -188,10 +202,9 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi assert 0 <= blackpoint <= midpoint <= whitepoint <= 255 # Define colors from arguments - black = _color(black, "RGB") - white = _color(white, "RGB") - if mid is not None: - mid = _color(mid, "RGB") + rgb_black = cast(Sequence[int], _color(black, "RGB")) + rgb_white = cast(Sequence[int], _color(white, "RGB")) + rgb_mid = cast(Sequence[int], _color(mid, "RGB")) if mid is not None else None # Empty lists for the mapping red = [] @@ -200,18 +213,24 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi # Create the low-end values for i in range(0, blackpoint): - red.append(black[0]) - green.append(black[1]) - blue.append(black[2]) + red.append(rgb_black[0]) + green.append(rgb_black[1]) + blue.append(rgb_black[2]) # Create the mapping (2-color) - if mid is None: + if rgb_mid is None: range_map = range(0, whitepoint - blackpoint) for i in range_map: - red.append(black[0] + i * (white[0] - black[0]) // len(range_map)) - green.append(black[1] + i * (white[1] - black[1]) // len(range_map)) - blue.append(black[2] + i * (white[2] - black[2]) // len(range_map)) + red.append( + rgb_black[0] + i * (rgb_white[0] - rgb_black[0]) // len(range_map) + ) + green.append( + rgb_black[1] + i * (rgb_white[1] - rgb_black[1]) // len(range_map) + ) + blue.append( + rgb_black[2] + i * (rgb_white[2] - rgb_black[2]) // len(range_map) + ) # Create the mapping (3-color) else: @@ -219,26 +238,36 @@ def colorize(image, black, white, mid=None, blackpoint=0, whitepoint=255, midpoi range_map2 = range(0, whitepoint - midpoint) for i in range_map1: - red.append(black[0] + i * (mid[0] - black[0]) // len(range_map1)) - green.append(black[1] + i * (mid[1] - black[1]) // len(range_map1)) - blue.append(black[2] + i * (mid[2] - black[2]) // len(range_map1)) + red.append( + rgb_black[0] + i * (rgb_mid[0] - rgb_black[0]) // len(range_map1) + ) + green.append( + rgb_black[1] + i * (rgb_mid[1] - rgb_black[1]) // len(range_map1) + ) + blue.append( + rgb_black[2] + i * (rgb_mid[2] - rgb_black[2]) // len(range_map1) + ) for i in range_map2: - red.append(mid[0] + i * (white[0] - mid[0]) // len(range_map2)) - green.append(mid[1] + i * (white[1] - mid[1]) // len(range_map2)) - blue.append(mid[2] + i * (white[2] - mid[2]) // len(range_map2)) + red.append(rgb_mid[0] + i * (rgb_white[0] - rgb_mid[0]) // len(range_map2)) + green.append( + rgb_mid[1] + i * (rgb_white[1] - rgb_mid[1]) // len(range_map2) + ) + blue.append(rgb_mid[2] + i * (rgb_white[2] - rgb_mid[2]) // len(range_map2)) # Create the high-end values for i in range(0, 256 - whitepoint): - red.append(white[0]) - green.append(white[1]) - blue.append(white[2]) + red.append(rgb_white[0]) + green.append(rgb_white[1]) + blue.append(rgb_white[2]) # Return converted image image = image.convert("RGB") return _lut(image, red + green + blue) -def contain(image, size, method=Image.Resampling.BICUBIC): +def contain( + image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC +) -> Image.Image: """ Returns a resized version of the image, set to the maximum width and height within the requested size, while maintaining the original aspect ratio. @@ -267,7 +296,9 @@ def contain(image, size, method=Image.Resampling.BICUBIC): return image.resize(size, resample=method) -def cover(image, size, method=Image.Resampling.BICUBIC): +def cover( + image: Image.Image, size: tuple[int, int], method: int = Image.Resampling.BICUBIC +) -> Image.Image: """ Returns a resized version of the image, so that the requested size is covered, while maintaining the original aspect ratio. @@ -296,7 +327,13 @@ def cover(image, size, method=Image.Resampling.BICUBIC): return image.resize(size, resample=method) -def pad(image, size, method=Image.Resampling.BICUBIC, color=None, centering=(0.5, 0.5)): +def pad( + image: Image.Image, + size: tuple[int, int], + method: int = Image.Resampling.BICUBIC, + color: str | int | tuple[int, ...] | None = None, + centering: tuple[float, float] = (0.5, 0.5), +) -> Image.Image: """ Returns a resized and padded version of the image, expanded to fill the requested aspect ratio and size. @@ -334,7 +371,7 @@ def pad(image, size, method=Image.Resampling.BICUBIC, color=None, centering=(0.5 return out -def crop(image, border=0): +def crop(image: Image.Image, border: int = 0) -> Image.Image: """ Remove border from image. The same amount of pixels are removed from all four sides. This function works on all image modes. @@ -349,7 +386,9 @@ def crop(image, border=0): return image.crop((left, top, image.size[0] - right, image.size[1] - bottom)) -def scale(image, factor, resample=Image.Resampling.BICUBIC): +def scale( + image: Image.Image, factor: float, resample: int = Image.Resampling.BICUBIC +) -> Image.Image: """ Returns a rescaled image by a specific factor given in parameter. A factor greater than 1 expands the image, between 0 and 1 contracts the @@ -372,7 +411,27 @@ def scale(image, factor, resample=Image.Resampling.BICUBIC): return image.resize(size, resample) -def deform(image, deformer, resample=Image.Resampling.BILINEAR): +class SupportsGetMesh(Protocol): + """ + An object that supports the ``getmesh`` method, taking an image as an + argument, and returning a list of tuples. Each tuple contains two tuples, + the source box as a tuple of 4 integers, and a tuple of 8 integers for the + final quadrilateral, in order of top left, bottom left, bottom right, top + right. + """ + + def getmesh( + self, image: Image.Image + ) -> list[ + tuple[tuple[int, int, int, int], tuple[int, int, int, int, int, int, int, int]] + ]: ... + + +def deform( + image: Image.Image, + deformer: SupportsGetMesh, + resample: int = Image.Resampling.BILINEAR, +) -> Image.Image: """ Deform the image. @@ -388,7 +447,7 @@ def deform(image, deformer, resample=Image.Resampling.BILINEAR): ) -def equalize(image, mask=None): +def equalize(image: Image.Image, mask: Image.Image | None = None) -> Image.Image: """ Equalize the image histogram. This function applies a non-linear mapping to the input image, in order to create a uniform @@ -419,7 +478,11 @@ def equalize(image, mask=None): return _lut(image, lut) -def expand(image, border=0, fill=0): +def expand( + image: Image.Image, + border: int | tuple[int, ...] = 0, + fill: str | int | tuple[int, ...] = 0, +) -> Image.Image: """ Add border to the image @@ -445,7 +508,13 @@ def expand(image, border=0, fill=0): return out -def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5, 0.5)): +def fit( + image: Image.Image, + size: tuple[int, int], + method: int = Image.Resampling.BICUBIC, + bleed: float = 0.0, + centering: tuple[float, float] = (0.5, 0.5), +) -> Image.Image: """ Returns a resized and cropped version of the image, cropped to the requested aspect ratio and size. @@ -479,13 +548,12 @@ def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5, # kevin@cazabon.com # https://www.cazabon.com - # ensure centering is mutable - centering = list(centering) + centering_x, centering_y = centering - if not 0.0 <= centering[0] <= 1.0: - centering[0] = 0.5 - if not 0.0 <= centering[1] <= 1.0: - centering[1] = 0.5 + if not 0.0 <= centering_x <= 1.0: + centering_x = 0.5 + if not 0.0 <= centering_y <= 1.0: + centering_y = 0.5 if not 0.0 <= bleed < 0.5: bleed = 0.0 @@ -522,8 +590,8 @@ def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5, crop_height = live_size[0] / output_ratio # make the crop - crop_left = bleed_pixels[0] + (live_size[0] - crop_width) * centering[0] - crop_top = bleed_pixels[1] + (live_size[1] - crop_height) * centering[1] + crop_left = bleed_pixels[0] + (live_size[0] - crop_width) * centering_x + crop_top = bleed_pixels[1] + (live_size[1] - crop_height) * centering_y crop = (crop_left, crop_top, crop_left + crop_width, crop_top + crop_height) @@ -531,7 +599,7 @@ def fit(image, size, method=Image.Resampling.BICUBIC, bleed=0.0, centering=(0.5, return image.resize(size, method, box=crop) -def flip(image): +def flip(image: Image.Image) -> Image.Image: """ Flip the image vertically (top to bottom). @@ -541,7 +609,7 @@ def flip(image): return image.transpose(Image.Transpose.FLIP_TOP_BOTTOM) -def grayscale(image): +def grayscale(image: Image.Image) -> Image.Image: """ Convert the image to grayscale. @@ -551,7 +619,7 @@ def grayscale(image): return image.convert("L") -def invert(image): +def invert(image: Image.Image) -> Image.Image: """ Invert (negate) the image. @@ -562,7 +630,7 @@ def invert(image): return image.point(lut) if image.mode == "1" else _lut(image, lut) -def mirror(image): +def mirror(image: Image.Image) -> Image.Image: """ Flip image horizontally (left to right). @@ -572,7 +640,7 @@ def mirror(image): return image.transpose(Image.Transpose.FLIP_LEFT_RIGHT) -def posterize(image, bits): +def posterize(image: Image.Image, bits: int) -> Image.Image: """ Reduce the number of bits for each color channel. @@ -585,7 +653,7 @@ def posterize(image, bits): return _lut(image, lut) -def solarize(image, threshold=128): +def solarize(image: Image.Image, threshold: int = 128) -> Image.Image: """ Invert all pixel values above a threshold. @@ -602,7 +670,7 @@ def solarize(image, threshold=128): return _lut(image, lut) -def exif_transpose(image, *, in_place=False): +def exif_transpose(image: Image.Image, *, in_place: bool = False) -> Image.Image | None: """ If an image has an EXIF Orientation tag, other than 1, transpose the image accordingly, and remove the orientation data. @@ -616,7 +684,7 @@ def exif_transpose(image, *, in_place=False): """ image.load() image_exif = image.getexif() - orientation = image_exif.get(ExifTags.Base.Orientation) + orientation = image_exif.get(ExifTags.Base.Orientation, 1) method = { 2: Image.Transpose.FLIP_LEFT_RIGHT, 3: Image.Transpose.ROTATE_180, @@ -653,3 +721,4 @@ def exif_transpose(image, *, in_place=False): return transposed_image elif not in_place: return image.copy() + return None diff --git a/src/PIL/ImagePalette.py b/src/PIL/ImagePalette.py index fbcfa309d..770d10025 100644 --- a/src/PIL/ImagePalette.py +++ b/src/PIL/ImagePalette.py @@ -18,6 +18,7 @@ from __future__ import annotations import array +from typing import Sequence from . import GimpGradientFile, GimpPaletteFile, ImageColor, PaletteFile @@ -34,11 +35,11 @@ class ImagePalette: Defaults to an empty palette. """ - def __init__(self, mode="RGB", palette=None): + def __init__(self, mode: str = "RGB", palette: Sequence[int] | None = None) -> None: self.mode = mode self.rawmode = None # if set, palette contains raw data self.palette = palette or bytearray() - self.dirty = None + self.dirty: int | None = None @property def palette(self): @@ -127,7 +128,7 @@ class ImagePalette: raise ValueError(msg) from e return index - def getcolor(self, color, image=None): + def getcolor(self, color, image=None) -> int: """Given an rgb tuple, allocate palette entry. .. warning:: This method is experimental. @@ -192,7 +193,7 @@ class ImagePalette: # Internal -def raw(rawmode, data): +def raw(rawmode, data) -> ImagePalette: palette = ImagePalette() palette.rawmode = rawmode palette.palette = data diff --git a/src/PIL/ImageQt.py b/src/PIL/ImageQt.py index 6377c7501..293ba4941 100644 --- a/src/PIL/ImageQt.py +++ b/src/PIL/ImageQt.py @@ -19,19 +19,26 @@ from __future__ import annotations import sys from io import BytesIO +from typing import Callable from . import Image from ._util import is_path +qt_version: str | None qt_versions = [ ["6", "PyQt6"], ["side6", "PySide6"], ] # If a version has already been imported, attempt it first -qt_versions.sort(key=lambda qt_version: qt_version[1] in sys.modules, reverse=True) -for qt_version, qt_module in qt_versions: +qt_versions.sort(key=lambda version: version[1] in sys.modules, reverse=True) +for version, qt_module in qt_versions: try: + QBuffer: type + QIODevice: type + QImage: type + QPixmap: type + qRgba: Callable[[int, int, int, int], int] if qt_module == "PyQt6": from PyQt6.QtCore import QBuffer, QIODevice from PyQt6.QtGui import QImage, QPixmap, qRgba @@ -41,6 +48,7 @@ for qt_version, qt_module in qt_versions: except (ImportError, RuntimeError): continue qt_is_installed = True + qt_version = version break else: qt_is_installed = False diff --git a/src/PIL/ImageShow.py b/src/PIL/ImageShow.py index fad3e0980..4e505f2ee 100644 --- a/src/PIL/ImageShow.py +++ b/src/PIL/ImageShow.py @@ -13,18 +13,20 @@ # from __future__ import annotations +import abc import os import shutil import subprocess import sys from shlex import quote +from typing import Any from . import Image _viewers = [] -def register(viewer, order=1): +def register(viewer, order: int = 1) -> None: """ The :py:func:`register` function is used to register additional viewers:: @@ -49,7 +51,7 @@ def register(viewer, order=1): _viewers.insert(0, viewer) -def show(image, title=None, **options): +def show(image: Image.Image, title: str | None = None, **options: Any) -> bool: r""" Display a given image. @@ -69,7 +71,7 @@ class Viewer: # main api - def show(self, image, **options): + def show(self, image: Image.Image, **options: Any) -> int: """ The main function for displaying an image. Converts the given image to the target format and displays it. @@ -87,16 +89,16 @@ class Viewer: # hook methods - format = None + format: str | None = None """The format to convert the image into.""" - options = {} + options: dict[str, Any] = {} """Additional options used to convert the image.""" - def get_format(self, image): + def get_format(self, image: Image.Image) -> str | None: """Return format name, or ``None`` to save as PGM/PPM.""" return self.format - def get_command(self, file, **options): + def get_command(self, file: str, **options: Any) -> str: """ Returns the command used to display the file. Not implemented in the base class. @@ -104,15 +106,15 @@ class Viewer: msg = "unavailable in base viewer" raise NotImplementedError(msg) - def save_image(self, image): + def save_image(self, image: Image.Image) -> str: """Save to temporary file and return filename.""" return image._dump(format=self.get_format(image), **self.options) - def show_image(self, image, **options): + def show_image(self, image: Image.Image, **options: Any) -> int: """Display the given image.""" return self.show_file(self.save_image(image), **options) - def show_file(self, path, **options): + def show_file(self, path: str, **options: Any) -> int: """ Display given file. """ @@ -129,13 +131,24 @@ class WindowsViewer(Viewer): format = "PNG" options = {"compress_level": 1, "save_all": True} - def get_command(self, file, **options): + def get_command(self, file: str, **options: Any) -> str: return ( f'start "Pillow" /WAIT "{file}" ' "&& ping -n 4 127.0.0.1 >NUL " f'&& del /f "{file}"' ) + def show_file(self, path: str, **options: Any) -> int: + """ + Display given file. + """ + subprocess.Popen( + self.get_command(path, **options), + shell=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW"), + ) # nosec + return 1 + if sys.platform == "win32": register(WindowsViewer) @@ -147,14 +160,14 @@ class MacViewer(Viewer): format = "PNG" options = {"compress_level": 1, "save_all": True} - def get_command(self, file, **options): + def get_command(self, file: str, **options: Any) -> str: # on darwin open returns immediately resulting in the temp # file removal while app is opening command = "open -a Preview.app" command = f"({command} {quote(file)}; sleep 20; rm -f {quote(file)})&" return command - def show_file(self, path, **options): + def show_file(self, path: str, **options: Any) -> int: """ Display given file. """ @@ -180,7 +193,11 @@ class UnixViewer(Viewer): format = "PNG" options = {"compress_level": 1, "save_all": True} - def get_command(self, file, **options): + @abc.abstractmethod + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: + pass + + def get_command(self, file: str, **options: Any) -> str: command = self.get_command_ex(file, **options)[0] return f"({command} {quote(file)}" @@ -190,11 +207,11 @@ class XDGViewer(UnixViewer): The freedesktop.org ``xdg-open`` command. """ - def get_command_ex(self, file, **options): + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: command = executable = "xdg-open" return command, executable - def show_file(self, path, **options): + def show_file(self, path: str, **options: Any) -> int: """ Display given file. """ @@ -208,13 +225,15 @@ class DisplayViewer(UnixViewer): This viewer supports the ``title`` parameter. """ - def get_command_ex(self, file, title=None, **options): + def get_command_ex( + self, file: str, title: str | None = None, **options: Any + ) -> tuple[str, str]: command = executable = "display" if title: command += f" -title {quote(title)}" return command, executable - def show_file(self, path, **options): + def show_file(self, path: str, **options: Any) -> int: """ Display given file. """ @@ -231,12 +250,12 @@ class DisplayViewer(UnixViewer): class GmDisplayViewer(UnixViewer): """The GraphicsMagick ``gm display`` command.""" - def get_command_ex(self, file, **options): + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: executable = "gm" command = "gm display" return command, executable - def show_file(self, path, **options): + def show_file(self, path: str, **options: Any) -> int: """ Display given file. """ @@ -247,12 +266,12 @@ class GmDisplayViewer(UnixViewer): class EogViewer(UnixViewer): """The GNOME Image Viewer ``eog`` command.""" - def get_command_ex(self, file, **options): + def get_command_ex(self, file: str, **options: Any) -> tuple[str, str]: executable = "eog" command = "eog -n" return command, executable - def show_file(self, path, **options): + def show_file(self, path: str, **options: Any) -> int: """ Display given file. """ @@ -266,7 +285,9 @@ class XVViewer(UnixViewer): This viewer supports the ``title`` parameter. """ - def get_command_ex(self, file, title=None, **options): + def get_command_ex( + self, file: str, title: str | None = None, **options: Any + ) -> tuple[str, str]: # note: xv is pretty outdated. most modern systems have # imagemagick's display command instead. command = executable = "xv" @@ -274,7 +295,7 @@ class XVViewer(UnixViewer): command += f" -name {quote(title)}" return command, executable - def show_file(self, path, **options): + def show_file(self, path: str, **options: Any) -> int: """ Display given file. """ @@ -304,7 +325,7 @@ if sys.platform not in ("win32", "darwin"): # unixoids class IPythonViewer(Viewer): """The viewer for IPython frontends.""" - def show_image(self, image, **options): + def show_image(self, image: Image.Image, **options: Any) -> int: ipython_display(image) return 1 diff --git a/src/PIL/ImageTransform.py b/src/PIL/ImageTransform.py index 1fdaa9140..6aa82dadd 100644 --- a/src/PIL/ImageTransform.py +++ b/src/PIL/ImageTransform.py @@ -14,17 +14,29 @@ # from __future__ import annotations +from typing import Sequence + from . import Image class Transform(Image.ImageTransformHandler): - def __init__(self, data): + """Base class for other transforms defined in :py:mod:`~PIL.ImageTransform`.""" + + method: Image.Transform + + def __init__(self, data: Sequence[int]) -> None: self.data = data - def getdata(self): + def getdata(self) -> tuple[Image.Transform, Sequence[int]]: return self.method, self.data - def transform(self, size, image, **options): + def transform( + self, + size: tuple[int, int], + image: Image.Image, + **options: dict[str, str | int | tuple[int, ...] | list[int]], + ) -> Image.Image: + """Perform the transform. Called from :py:meth:`.Image.transform`.""" # can be overridden method, data = self.getdata() return image.transform(size, method, data, **options) @@ -42,7 +54,7 @@ class AffineTransform(Transform): This function can be used to scale, translate, rotate, and shear the original image. - See :py:meth:`~PIL.Image.Image.transform` + See :py:meth:`.Image.transform` :param matrix: A 6-tuple (a, b, c, d, e, f) containing the first two rows from an affine transform matrix. @@ -51,6 +63,26 @@ class AffineTransform(Transform): method = Image.Transform.AFFINE +class PerspectiveTransform(Transform): + """ + Define a perspective image transform. + + This function takes an 8-tuple (a, b, c, d, e, f, g, h). For each pixel + (x, y) in the output image, the new value is taken from a position + ((a x + b y + c) / (g x + h y + 1), (d x + e y + f) / (g x + h y + 1)) in + the input image, rounded to nearest pixel. + + This function can be used to scale, translate, rotate, and shear the + original image. + + See :py:meth:`.Image.transform` + + :param matrix: An 8-tuple (a, b, c, d, e, f, g, h). + """ + + method = Image.Transform.PERSPECTIVE + + class ExtentTransform(Transform): """ Define a transform to extract a subregion from an image. @@ -64,7 +96,7 @@ class ExtentTransform(Transform): rectangle in the current image. It is slightly slower than crop, but about as fast as a corresponding resize operation. - See :py:meth:`~PIL.Image.Image.transform` + See :py:meth:`.Image.transform` :param bbox: A 4-tuple (x0, y0, x1, y1) which specifies two points in the input image's coordinate system. See :ref:`coordinate-system`. @@ -80,7 +112,7 @@ class QuadTransform(Transform): Maps a quadrilateral (a region defined by four corners) from the image to a rectangle of the given size. - See :py:meth:`~PIL.Image.Image.transform` + See :py:meth:`.Image.transform` :param xy: An 8-tuple (x0, y0, x1, y1, x2, y2, x3, y3) which contain the upper left, lower left, lower right, and upper right corner of the @@ -95,7 +127,7 @@ class MeshTransform(Transform): Define a mesh image transform. A mesh transform consists of one or more individual quad transforms. - See :py:meth:`~PIL.Image.Image.transform` + See :py:meth:`.Image.transform` :param data: A list of (bbox, quad) tuples. """ diff --git a/src/PIL/ImtImagePlugin.py b/src/PIL/ImtImagePlugin.py index 7469c592d..abb3fb762 100644 --- a/src/PIL/ImtImagePlugin.py +++ b/src/PIL/ImtImagePlugin.py @@ -33,10 +33,12 @@ class ImtImageFile(ImageFile.ImageFile): format = "IMT" format_description = "IM Tools" - def _open(self): + def _open(self) -> None: # Quick rejection: if there's not a LF among the first # 100 bytes, this is (probably) not a text header. + assert self.fp is not None + buffer = self.fp.read(100) if b"\n" not in buffer: msg = "not an IM file" diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index e7dc3e4e4..409609434 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -16,30 +16,48 @@ # from __future__ import annotations -import os -import tempfile +from io import BytesIO +from typing import Sequence from . import Image, ImageFile -from ._binary import i8, o8 from ._binary import i16be as i16 from ._binary import i32be as i32 +from ._deprecate import deprecate COMPRESSION = {1: "raw", 5: "jpeg"} -PAD = o8(0) * 4 + +def __getattr__(name: str) -> bytes: + if name == "PAD": + deprecate("IptcImagePlugin.PAD", 12) + return b"\0\0\0\0" + msg = f"module '{__name__}' has no attribute '{name}'" + raise AttributeError(msg) # # Helpers -def i(c): - return i32((PAD + c)[-4:]) +def _i(c: bytes) -> int: + return i32((b"\0\0\0\0" + c)[-4:]) -def dump(c): +def _i8(c: int | bytes) -> int: + return c if isinstance(c, int) else c[0] + + +def i(c: bytes) -> int: + """.. deprecated:: 10.2.0""" + deprecate("IptcImagePlugin.i", 12) + return _i(c) + + +def dump(c: Sequence[int | bytes]) -> None: + """.. deprecated:: 10.2.0""" + deprecate("IptcImagePlugin.dump", 12) for i in c: - print("%02x" % i8(i), end=" ") + print("%02x" % _i8(i), end=" ") print() @@ -52,10 +70,10 @@ class IptcImageFile(ImageFile.ImageFile): format = "IPTC" format_description = "IPTC/NAA" - def getint(self, key): - return i(self.info[key]) + def getint(self, key: tuple[int, int]) -> int: + return _i(self.info[key]) - def field(self): + def field(self) -> tuple[tuple[int, int] | None, int]: # # get a IPTC field header s = self.fp.read(5) @@ -77,13 +95,13 @@ class IptcImageFile(ImageFile.ImageFile): elif size == 128: size = 0 elif size > 128: - size = i(self.fp.read(size - 128)) + size = _i(self.fp.read(size - 128)) else: size = i16(s, 3) return tag, size - def _open(self): + def _open(self) -> None: # load descriptive fields while True: offset = self.fp.tell() @@ -103,10 +121,10 @@ class IptcImageFile(ImageFile.ImageFile): self.info[tag] = tagdata # mode - layers = i8(self.info[(3, 60)][0]) - component = i8(self.info[(3, 60)][1]) + layers = self.info[(3, 60)][0] + component = self.info[(3, 60)][1] if (3, 65) in self.info: - id = i8(self.info[(3, 65)][0]) - 1 + id = self.info[(3, 65)][0] - 1 else: id = 0 if layers == 1 and not component: @@ -128,27 +146,22 @@ class IptcImageFile(ImageFile.ImageFile): # tile if tag == (8, 10): - self.tile = [ - ("iptc", (compression, offset), (0, 0, self.size[0], self.size[1])) - ] + self.tile = [("iptc", (0, 0) + self.size, offset, compression)] def load(self): if len(self.tile) != 1 or self.tile[0][0] != "iptc": return ImageFile.ImageFile.load(self) - type, tile, box = self.tile[0] - - encoding, offset = tile + offset, compression = self.tile[0][2:] self.fp.seek(offset) # Copy image data to temporary file - o_fd, outfile = tempfile.mkstemp(text=False) - o = os.fdopen(o_fd) - if encoding == "raw": + o = BytesIO() + if compression == "raw": # To simplify access to the extracted file, # prepend a PPM header - o.write("P5\n%d %d\n255\n" % self.size) + o.write(b"P5\n%d %d\n255\n" % self.size) while True: type, size = self.field() if type != (8, 10): @@ -159,17 +172,10 @@ class IptcImageFile(ImageFile.ImageFile): break o.write(s) size -= len(s) - o.close() - try: - with Image.open(outfile) as _im: - _im.load() - self.im = _im.im - finally: - try: - os.unlink(outfile) - except OSError: - pass + with Image.open(o) as _im: + _im.load() + self.im = _im.im Image.register_open(IptcImageFile.format, IptcImageFile) @@ -185,8 +191,6 @@ def getiptcinfo(im): :returns: A dictionary containing IPTC information, or None if no IPTC information block was found. """ - import io - from . import JpegImagePlugin, TiffImagePlugin data = None @@ -221,7 +225,7 @@ def getiptcinfo(im): # parse the IPTC information chunk im.info = {} - im.fp = io.BytesIO(data) + im.fp = BytesIO(data) try: im._open() diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 59bade303..81b8749a3 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -87,10 +87,12 @@ def APP(self, marker): self.info["dpi"] = jfif_density self.info["jfif_unit"] = jfif_unit self.info["jfif_density"] = jfif_density - elif marker == 0xFFE1 and s[:5] == b"Exif\0": - if "exif" not in self.info: - # extract EXIF information (incomplete) - self.info["exif"] = s # FIXME: value will change + elif marker == 0xFFE1 and s[:6] == b"Exif\0\0": + # extract EXIF information + if "exif" in self.info: + self.info["exif"] += s[6:] + else: + self.info["exif"] = s self._exif_offset = self.fp.tell() - n + 6 elif marker == 0xFFE2 and s[:5] == b"FPXR\0": # extract FlashPix information (incomplete) @@ -783,6 +785,7 @@ def _save(im, fp, filename): progressive, info.get("smooth", 0), optimize, + info.get("keep_rgb", False), info.get("streamtype", 0), dpi[0], dpi[1], diff --git a/src/PIL/JpegPresets.py b/src/PIL/JpegPresets.py index 9ecfdb259..d0e64a35e 100644 --- a/src/PIL/JpegPresets.py +++ b/src/PIL/JpegPresets.py @@ -62,6 +62,7 @@ Libjpeg ref.: https://web.archive.org/web/20120328125543/http://www.jpegcameras.com/libjpeg/libjpeg-3.html """ + from __future__ import annotations # fmt: off diff --git a/src/PIL/McIdasImagePlugin.py b/src/PIL/McIdasImagePlugin.py index 9a85c0d15..27972236c 100644 --- a/src/PIL/McIdasImagePlugin.py +++ b/src/PIL/McIdasImagePlugin.py @@ -22,8 +22,8 @@ import struct from . import Image, ImageFile -def _accept(s): - return s[:8] == b"\x00\x00\x00\x00\x00\x00\x00\x04" +def _accept(prefix: bytes) -> bool: + return prefix[:8] == b"\x00\x00\x00\x00\x00\x00\x00\x04" ## @@ -34,8 +34,10 @@ class McIdasImageFile(ImageFile.ImageFile): format = "MCIDAS" format_description = "McIdas area file" - def _open(self): + def _open(self) -> None: # parse area file directory + assert self.fp is not None + s = self.fp.read(256) if not _accept(s) or len(s) != 256: msg = "not an McIdas area file" diff --git a/src/PIL/MpegImagePlugin.py b/src/PIL/MpegImagePlugin.py index f4e598ca3..1565612f8 100644 --- a/src/PIL/MpegImagePlugin.py +++ b/src/PIL/MpegImagePlugin.py @@ -16,21 +16,22 @@ from __future__ import annotations from . import Image, ImageFile from ._binary import i8 +from ._typing import SupportsRead # # Bitstream parser class BitStream: - def __init__(self, fp): + def __init__(self, fp: SupportsRead[bytes]) -> None: self.fp = fp self.bits = 0 self.bitbuffer = 0 - def next(self): + def next(self) -> int: return i8(self.fp.read(1)) - def peek(self, bits): + def peek(self, bits: int) -> int: while self.bits < bits: c = self.next() if c < 0: @@ -40,13 +41,13 @@ class BitStream: self.bits += 8 return self.bitbuffer >> (self.bits - bits) & (1 << bits) - 1 - def skip(self, bits): + def skip(self, bits: int) -> None: while self.bits < bits: self.bitbuffer = (self.bitbuffer << 8) + i8(self.fp.read(1)) self.bits += 8 self.bits = self.bits - bits - def read(self, bits): + def read(self, bits: int) -> int: v = self.peek(bits) self.bits = self.bits - bits return v @@ -61,9 +62,10 @@ class MpegImageFile(ImageFile.ImageFile): format = "MPEG" format_description = "MPEG" - def _open(self): - s = BitStream(self.fp) + def _open(self) -> None: + assert self.fp is not None + s = BitStream(self.fp) if s.read(32) != 0x1B3: msg = "not an MPEG file" raise SyntaxError(msg) diff --git a/src/PIL/MspImagePlugin.py b/src/PIL/MspImagePlugin.py index 77dac65b6..65cc70624 100644 --- a/src/PIL/MspImagePlugin.py +++ b/src/PIL/MspImagePlugin.py @@ -26,6 +26,7 @@ from __future__ import annotations import io import struct +from typing import IO from . import Image, ImageFile from ._binary import i16le as i16 @@ -35,7 +36,7 @@ from ._binary import o16le as o16 # read MSP files -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] in [b"DanM", b"LinS"] @@ -48,8 +49,10 @@ class MspImageFile(ImageFile.ImageFile): format = "MSP" format_description = "Windows Paint" - def _open(self): + def _open(self) -> None: # Header + assert self.fp is not None + s = self.fp.read(32) if not _accept(s): msg = "not an MSP file" @@ -109,7 +112,9 @@ class MspDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: + assert self.fd is not None + img = io.BytesIO() blank_line = bytearray((0xFF,) * ((self.state.xsize + 7) // 8)) try: @@ -159,7 +164,7 @@ Image.register_decoder("MSP", MspDecoder) # write MSP files (uncompressed only) -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: if im.mode != "1": msg = f"cannot write mode {im.mode} as MSP" raise OSError(msg) diff --git a/src/PIL/PcdImagePlugin.py b/src/PIL/PcdImagePlugin.py index a0515b302..1cd5c4a9d 100644 --- a/src/PIL/PcdImagePlugin.py +++ b/src/PIL/PcdImagePlugin.py @@ -27,8 +27,10 @@ class PcdImageFile(ImageFile.ImageFile): format = "PCD" format_description = "Kodak PhotoCD" - def _open(self): + def _open(self) -> None: # rough + assert self.fp is not None + self.fp.seek(2048) s = self.fp.read(2048) @@ -47,9 +49,11 @@ class PcdImageFile(ImageFile.ImageFile): self._size = 768, 512 # FIXME: not correct for rotated images! self.tile = [("pcd", (0, 0) + self.size, 96 * 2048, None)] - def load_end(self): + def load_end(self) -> None: if self.tile_post_rotate: # Handle rotated PCDs + assert self.im is not None + self.im = self.im.rotate(self.tile_post_rotate) self._size = self.im.size diff --git a/src/PIL/PcfFontFile.py b/src/PIL/PcfFontFile.py index d602a1633..0d1968b14 100644 --- a/src/PIL/PcfFontFile.py +++ b/src/PIL/PcfFontFile.py @@ -18,6 +18,7 @@ from __future__ import annotations import io +from typing import BinaryIO, Callable from . import FontFile, Image from ._binary import i8 @@ -41,7 +42,7 @@ PCF_SWIDTHS = 1 << 6 PCF_GLYPH_NAMES = 1 << 7 PCF_BDF_ACCELERATORS = 1 << 8 -BYTES_PER_ROW = [ +BYTES_PER_ROW: list[Callable[[int], int]] = [ lambda bits: ((bits + 7) >> 3), lambda bits: ((bits + 15) >> 3) & ~1, lambda bits: ((bits + 31) >> 3) & ~3, @@ -49,7 +50,7 @@ BYTES_PER_ROW = [ ] -def sz(s, o): +def sz(s: bytes, o: int) -> bytes: return s[o : s.index(b"\0", o)] @@ -58,7 +59,7 @@ class PcfFontFile(FontFile.FontFile): name = "name" - def __init__(self, fp, charset_encoding="iso8859-1"): + def __init__(self, fp: BinaryIO, charset_encoding: str = "iso8859-1"): self.charset_encoding = charset_encoding magic = l32(fp.read(4)) @@ -104,7 +105,9 @@ class PcfFontFile(FontFile.FontFile): bitmaps[ix], ) - def _getformat(self, tag): + def _getformat( + self, tag: int + ) -> tuple[BinaryIO, int, Callable[[bytes], int], Callable[[bytes], int]]: format, size, offset = self.toc[tag] fp = self.fp @@ -119,7 +122,7 @@ class PcfFontFile(FontFile.FontFile): return fp, format, i16, i32 - def _load_properties(self): + def _load_properties(self) -> dict[bytes, bytes | int]: # # font properties @@ -138,18 +141,16 @@ class PcfFontFile(FontFile.FontFile): data = fp.read(i32(fp.read(4))) for k, s, v in p: - k = sz(data, k) - if s: - v = sz(data, v) - properties[k] = v + property_value: bytes | int = sz(data, v) if s else v + properties[sz(data, k)] = property_value return properties - def _load_metrics(self): + def _load_metrics(self) -> list[tuple[int, int, int, int, int, int, int, int]]: # # font metrics - metrics = [] + metrics: list[tuple[int, int, int, int, int, int, int, int]] = [] fp, format, i16, i32 = self._getformat(PCF_METRICS) @@ -182,7 +183,9 @@ class PcfFontFile(FontFile.FontFile): return metrics - def _load_bitmaps(self, metrics): + def _load_bitmaps( + self, metrics: list[tuple[int, int, int, int, int, int, int, int]] + ) -> list[Image.Image]: # # bitmap data @@ -222,7 +225,7 @@ class PcfFontFile(FontFile.FontFile): return bitmaps - def _load_encoding(self): + def _load_encoding(self) -> list[int | None]: fp, format, i16, i32 = self._getformat(PCF_BDF_ENCODINGS) first_col, last_col = i16(fp.read(2)), i16(fp.read(2)) @@ -233,7 +236,7 @@ class PcfFontFile(FontFile.FontFile): nencoding = (last_col - first_col + 1) * (last_row - first_row + 1) # map character code to bitmap index - encoding = [None] * min(256, nencoding) + encoding: list[int | None] = [None] * min(256, nencoding) encoding_offsets = [i16(fp.read(2)) for _ in range(nencoding)] diff --git a/src/PIL/PcxImagePlugin.py b/src/PIL/PcxImagePlugin.py index 98ecefd05..026bfd9a0 100644 --- a/src/PIL/PcxImagePlugin.py +++ b/src/PIL/PcxImagePlugin.py @@ -28,6 +28,7 @@ from __future__ import annotations import io import logging +from typing import IO from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 @@ -37,7 +38,7 @@ from ._binary import o16le as o16 logger = logging.getLogger(__name__) -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[0] == 10 and prefix[1] in [0, 2, 3, 5] @@ -49,8 +50,10 @@ class PcxImageFile(ImageFile.ImageFile): format = "PCX" format_description = "Paintbrush" - def _open(self): + def _open(self) -> None: # header + assert self.fp is not None + s = self.fp.read(128) if not _accept(s): msg = "not a PCX file" @@ -141,7 +144,7 @@ SAVE = { } -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: try: version, bits, planes, rawmode = SAVE[im.mode] except KeyError as e: @@ -199,6 +202,8 @@ def _save(im, fp, filename): if im.mode == "P": # colour palette + assert im.im is not None + fp.write(o8(12)) palette = im.im.getpalette("RGB", "RGB") palette += b"\x00" * (768 - len(palette)) diff --git a/src/PIL/PdfImagePlugin.py b/src/PIL/PdfImagePlugin.py index 3506aadce..1777f1f20 100644 --- a/src/PIL/PdfImagePlugin.py +++ b/src/PIL/PdfImagePlugin.py @@ -188,9 +188,9 @@ def _save(im, fp, filename, save_all=False): x_resolution = y_resolution = im.encoderinfo.get("resolution", 72.0) info = { - "title": None - if is_appending - else os.path.splitext(os.path.basename(filename))[0], + "title": ( + None if is_appending else os.path.splitext(os.path.basename(filename))[0] + ), "author": None, "subject": None, "keywords": None, diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index 014460006..4c5101738 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -8,6 +8,7 @@ import os import re import time import zlib +from typing import TYPE_CHECKING, Any, List, Union # see 7.9.2.2 Text String Type on page 86 and D.3 PDFDocEncoding Character Set @@ -239,12 +240,18 @@ class PdfName: return bytes(result) -class PdfArray(list): +class PdfArray(List[Any]): def __bytes__(self): return b"[ " + b" ".join(pdf_repr(x) for x in self) + b" ]" -class PdfDict(collections.UserDict): +if TYPE_CHECKING: + _DictBase = collections.UserDict[Union[str, bytes], Any] +else: + _DictBase = collections.UserDict + + +class PdfDict(_DictBase): def __setattr__(self, key, value): if key == "data": collections.UserDict.__setattr__(self, key, value) diff --git a/src/PIL/PixarImagePlugin.py b/src/PIL/PixarImagePlugin.py index af866feb3..887b6568b 100644 --- a/src/PIL/PixarImagePlugin.py +++ b/src/PIL/PixarImagePlugin.py @@ -27,7 +27,7 @@ from ._binary import i16le as i16 # helpers -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:4] == b"\200\350\000\000" @@ -39,8 +39,10 @@ class PixarImageFile(ImageFile.ImageFile): format = "PIXAR" format_description = "PIXAR raster image" - def _open(self): + def _open(self) -> None: # assuming a 4-byte magic label + assert self.fp is not None + s = self.fp.read(4) if not _accept(s): msg = "not a PIXAR file" diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index e4ed93880..d922bacfb 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -62,7 +62,7 @@ _MODES = { (2, 0): ("L", "L;2"), (4, 0): ("L", "L;4"), (8, 0): ("L", "L"), - (16, 0): ("I", "I;16B"), + (16, 0): ("I;16", "I;16B"), # Truecolour (8, 2): ("RGB", "RGB"), (16, 2): ("RGB", "RGB;16B"), @@ -378,7 +378,7 @@ class PngStream(ChunkStream): } def rewind(self): - self.im_info = self.rewind_state["info"] + self.im_info = self.rewind_state["info"].copy() self.im_tile = self.rewind_state["tile"] self._seq_num = self.rewind_state["seq_num"] @@ -392,8 +392,8 @@ class PngStream(ChunkStream): # Compressed profile n bytes (zlib with deflate compression) i = s.find(b"\0") logger.debug("iCCP profile name %r", s[:i]) - logger.debug("Compression method %s", s[i]) - comp_method = s[i] + comp_method = s[i + 1] + logger.debug("Compression method %s", comp_method) if comp_method != 0: msg = f"Unknown compression method {comp_method} in iCCP chunk" raise SyntaxError(msg) @@ -467,7 +467,7 @@ class PngStream(ChunkStream): # otherwise, we have a byte string with one alpha value # for each palette entry self.im_info["transparency"] = s - elif self.im_mode in ("1", "L", "I"): + elif self.im_mode in ("1", "L", "I;16"): self.im_info["transparency"] = i16(s) elif self.im_mode == "RGB": self.im_info["transparency"] = i16(s), i16(s, 2), i16(s, 4) @@ -981,7 +981,13 @@ class PngImageFile(ImageFile.ImageFile): except EOFError: if cid == b"fdAT": length -= 4 - ImageFile._safe_read(self.fp, length) + try: + ImageFile._safe_read(self.fp, length) + except OSError as e: + if ImageFile.LOAD_TRUNCATED_IMAGES: + break + else: + raise e except AttributeError: logger.debug("%r %s %s (unknown)", cid, pos, length) s = ImageFile._safe_read(self.fp, length) @@ -1232,16 +1238,20 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): "default_image", im.info.get("default_image") ) modes = set() + sizes = set() append_images = im.encoderinfo.get("append_images", []) for im_seq in itertools.chain([im], append_images): for im_frame in ImageSequence.Iterator(im_seq): modes.add(im_frame.mode) + sizes.add(im_frame.size) for mode in ("RGBA", "RGB", "P"): if mode in modes: break else: mode = modes.pop() + size = tuple(max(frame_size[i] for frame_size in sizes) for i in range(2)) else: + size = im.size mode = im.mode if mode == "P": @@ -1289,8 +1299,8 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): chunk( fp, b"IHDR", - o32(im.size[0]), # 0: size - o32(im.size[1]), + o32(size[0]), # 0: size + o32(size[1]), mode, # 8: depth/type b"\0", # 10: compression b"\0", # 11: filter category @@ -1350,7 +1360,7 @@ def _save(im, fp, filename, chunk=putchunk, save_all=False): transparency = max(0, min(255, transparency)) alpha = b"\xFF" * transparency + b"\0" chunk(fp, b"tRNS", alpha[:alpha_bytes]) - elif im.mode in ("1", "L", "I"): + elif im.mode in ("1", "L", "I", "I;16"): transparency = max(0, min(65535, transparency)) chunk(fp, b"tRNS", o16(transparency)) elif im.mode == "RGB": diff --git a/src/PIL/PpmImagePlugin.py b/src/PIL/PpmImagePlugin.py index 25dbfa5b0..6ac7a9bbc 100644 --- a/src/PIL/PpmImagePlugin.py +++ b/src/PIL/PpmImagePlugin.py @@ -15,6 +15,9 @@ # from __future__ import annotations +import math +from typing import IO + from . import Image, ImageFile from ._binary import i16be as i16 from ._binary import o8 @@ -35,6 +38,7 @@ MODES = { b"P6": "RGB", # extensions b"P0CMYK": "CMYK", + b"Pf": "F", # PIL extensions (for test purposes only) b"PyP": "P", b"PyRGBA": "RGBA", @@ -42,8 +46,8 @@ MODES = { } -def _accept(prefix): - return prefix[0:1] == b"P" and prefix[1] in b"0123456y" +def _accept(prefix: bytes) -> bool: + return prefix[0:1] == b"P" and prefix[1] in b"0123456fy" ## @@ -54,7 +58,9 @@ class PpmImageFile(ImageFile.ImageFile): format = "PPM" format_description = "Pbmplus image" - def _read_magic(self): + def _read_magic(self) -> bytes: + assert self.fp is not None + magic = b"" # read until whitespace or longest available magic number for _ in range(6): @@ -64,7 +70,9 @@ class PpmImageFile(ImageFile.ImageFile): magic += c return magic - def _read_token(self): + def _read_token(self) -> bytes: + assert self.fp is not None + token = b"" while len(token) <= 10: # read until next whitespace or limit of 10 characters c = self.fp.read(1) @@ -90,13 +98,16 @@ class PpmImageFile(ImageFile.ImageFile): raise ValueError(msg) return token - def _open(self): + def _open(self) -> None: + assert self.fp is not None + magic_number = self._read_magic() try: mode = MODES[magic_number] except KeyError: msg = "not a PPM file" raise SyntaxError(msg) + self._mode = mode if magic_number in (b"P1", b"P4"): self.custom_mimetype = "image/x-portable-bitmap" @@ -105,40 +116,42 @@ class PpmImageFile(ImageFile.ImageFile): elif magic_number in (b"P3", b"P6"): self.custom_mimetype = "image/x-portable-pixmap" - maxval = None + self._size = int(self._read_token()), int(self._read_token()) + decoder_name = "raw" if magic_number in (b"P1", b"P2", b"P3"): decoder_name = "ppm_plain" - for ix in range(3): - token = int(self._read_token()) - if ix == 0: # token is the x size - xsize = token - elif ix == 1: # token is the y size - ysize = token - if mode == "1": - self._mode = "1" - rawmode = "1;I" - break - else: - self._mode = rawmode = mode - elif ix == 2: # token is maxval - maxval = token - if not 0 < maxval < 65536: - msg = "maxval must be greater than 0 and less than 65536" - raise ValueError(msg) - if maxval > 255 and mode == "L": - self._mode = "I" - if decoder_name != "ppm_plain": - # If maxval matches a bit depth, use the raw decoder directly - if maxval == 65535 and mode == "L": - rawmode = "I;16B" - elif maxval != 255: - decoder_name = "ppm" + args: str | tuple[str | int, ...] + if mode == "1": + args = "1;I" + elif mode == "F": + scale = float(self._read_token()) + if scale == 0.0 or not math.isfinite(scale): + msg = "scale must be finite and non-zero" + raise ValueError(msg) + self.info["scale"] = abs(scale) - args = (rawmode, 0, 1) if decoder_name == "raw" else (rawmode, maxval) - self._size = xsize, ysize - self.tile = [(decoder_name, (0, 0, xsize, ysize), self.fp.tell(), args)] + rawmode = "F;32F" if scale < 0 else "F;32BF" + args = (rawmode, 0, -1) + else: + maxval = int(self._read_token()) + if not 0 < maxval < 65536: + msg = "maxval must be greater than 0 and less than 65536" + raise ValueError(msg) + if maxval > 255 and mode == "L": + self._mode = "I" + + rawmode = mode + if decoder_name != "ppm_plain": + # If maxval matches a bit depth, use the raw decoder directly + if maxval == 65535 and mode == "L": + rawmode = "I;16B" + elif maxval != 255: + decoder_name = "ppm" + + args = rawmode if decoder_name == "raw" else (rawmode, maxval) + self.tile = [(decoder_name, (0, 0) + self.size, self.fp.tell(), args)] # @@ -147,16 +160,19 @@ class PpmImageFile(ImageFile.ImageFile): class PpmPlainDecoder(ImageFile.PyDecoder): _pulls_fd = True + _comment_spans: bool + + def _read_block(self) -> bytes: + assert self.fd is not None - def _read_block(self): return self.fd.read(ImageFile.SAFEBLOCK) - def _find_comment_end(self, block, start=0): + def _find_comment_end(self, block: bytes, start: int = 0) -> int: a = block.find(b"\n", start) b = block.find(b"\r", start) return min(a, b) if a * b > 0 else max(a, b) # lowest nonnegative index (or -1) - def _ignore_comments(self, block): + def _ignore_comments(self, block: bytes) -> bytes: if self._comment_spans: # Finish current comment while block: @@ -190,7 +206,7 @@ class PpmPlainDecoder(ImageFile.PyDecoder): break return block - def _decode_bitonal(self): + def _decode_bitonal(self) -> bytearray: """ This is a separate method because in the plain PBM format, all data tokens are exactly one byte, so the inter-token whitespace is optional. @@ -215,7 +231,7 @@ class PpmPlainDecoder(ImageFile.PyDecoder): invert = bytes.maketrans(b"01", b"\xFF\x00") return data.translate(invert) - def _decode_blocks(self, maxval): + def _decode_blocks(self, maxval: int) -> bytearray: data = bytearray() max_len = 10 out_byte_count = 4 if self.mode == "I" else 1 @@ -223,7 +239,7 @@ class PpmPlainDecoder(ImageFile.PyDecoder): bands = Image.getmodebands(self.mode) total_bytes = self.state.xsize * self.state.ysize * bands * out_byte_count - half_token = False + half_token = b"" while len(data) != total_bytes: block = self._read_block() # read next block if not block: @@ -237,7 +253,7 @@ class PpmPlainDecoder(ImageFile.PyDecoder): if half_token: block = half_token + block # stitch half_token to new block - half_token = False + half_token = b"" tokens = block.split() @@ -255,15 +271,15 @@ class PpmPlainDecoder(ImageFile.PyDecoder): raise ValueError(msg) value = int(token) if value > maxval: - msg = f"Channel value too large for this mode: {value}" - raise ValueError(msg) + msg_str = f"Channel value too large for this mode: {value}" + raise ValueError(msg_str) value = round(value / maxval * out_max) data += o32(value) if self.mode == "I" else o8(value) if len(data) == total_bytes: # finished! break return data - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: self._comment_spans = False if self.mode == "1": data = self._decode_bitonal() @@ -279,7 +295,9 @@ class PpmPlainDecoder(ImageFile.PyDecoder): class PpmDecoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: + assert self.fd is not None + data = bytearray() maxval = self.args[-1] in_byte_count = 1 if maxval < 256 else 2 @@ -306,7 +324,7 @@ class PpmDecoder(ImageFile.PyDecoder): # -------------------------------------------------------------------- -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: if im.mode == "1": rawmode, head = "1;I", b"P4" elif im.mode == "L": @@ -315,6 +333,8 @@ def _save(im, fp, filename): rawmode, head = "I;16B", b"P5" elif im.mode in ("RGB", "RGBA"): rawmode, head = "RGB", b"P6" + elif im.mode == "F": + rawmode, head = "F;32F", b"Pf" else: msg = f"cannot write mode {im.mode} as PPM" raise OSError(msg) @@ -326,7 +346,10 @@ def _save(im, fp, filename): fp.write(b"255\n") else: fp.write(b"65535\n") - ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, 1))]) + elif head == b"Pf": + fp.write(b"-1.0\n") + row_order = -1 if im.mode == "F" else 1 + ImageFile._save(im, fp, [("raw", (0, 0) + im.size, 0, (rawmode, 0, row_order))]) # @@ -339,6 +362,6 @@ Image.register_save(PpmImageFile.format, _save) Image.register_decoder("ppm", PpmDecoder) Image.register_decoder("ppm_plain", PpmPlainDecoder) -Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm"]) +Image.register_extensions(PpmImageFile.format, [".pbm", ".pgm", ".ppm", ".pnm", ".pfm"]) Image.register_mime(PpmImageFile.format, "image/x-portable-anymap") diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index 5cff56413..d29bcf997 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -24,6 +24,7 @@ from ._binary import i8 from ._binary import i16be as i16 from ._binary import i32be as i32 from ._binary import si16be as si16 +from ._binary import si32be as si32 MODES = { # (photoshop mode, bits) -> (pil mode, required channels) @@ -177,22 +178,21 @@ def _layerinfo(fp, ct_bytes): for _ in range(abs(ct)): # bounding box - y0 = i32(read(4)) - x0 = i32(read(4)) - y1 = i32(read(4)) - x1 = i32(read(4)) + y0 = si32(read(4)) + x0 = si32(read(4)) + y1 = si32(read(4)) + x1 = si32(read(4)) # image info mode = [] ct_types = i16(read(2)) - types = list(range(ct_types)) - if len(types) > 4: - fp.seek(len(types) * 6 + 12, io.SEEK_CUR) + if ct_types > 4: + fp.seek(ct_types * 6 + 12, io.SEEK_CUR) size = i32(read(4)) fp.seek(size, io.SEEK_CUR) continue - for _ in types: + for _ in range(ct_types): type = i16(read(2)) if type == 65535: diff --git a/src/PIL/PyAccess.py b/src/PIL/PyAccess.py index 23ff154f6..2c831913d 100644 --- a/src/PIL/PyAccess.py +++ b/src/PIL/PyAccess.py @@ -25,6 +25,7 @@ import sys from ._deprecate import deprecate +FFI: type try: from cffi import FFI @@ -43,7 +44,7 @@ except ImportError as ex: # anything in core. from ._util import DeferredError - FFI = ffi = DeferredError(ex) + FFI = ffi = DeferredError.new(ex) logger = logging.getLogger(__name__) diff --git a/src/PIL/SgiImagePlugin.py b/src/PIL/SgiImagePlugin.py index f9a10f610..7bd84ebd4 100644 --- a/src/PIL/SgiImagePlugin.py +++ b/src/PIL/SgiImagePlugin.py @@ -24,13 +24,14 @@ from __future__ import annotations import os import struct +from typing import IO from . import Image, ImageFile from ._binary import i16be as i16 from ._binary import o8 -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return len(prefix) >= 2 and i16(prefix) == 474 @@ -52,8 +53,10 @@ class SgiImageFile(ImageFile.ImageFile): format = "SGI" format_description = "SGI Image File Format" - def _open(self): + def _open(self) -> None: # HEAD + assert self.fp is not None + headlen = 512 s = self.fp.read(headlen) @@ -122,7 +125,7 @@ class SgiImageFile(ImageFile.ImageFile): ] -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: if im.mode not in {"RGB", "RGBA", "L"}: msg = "Unsupported SGI image mode" raise ValueError(msg) @@ -168,8 +171,8 @@ def _save(im, fp, filename): # Maximum Byte value (255 = 8bits per pixel) pinmax = 255 # Image name (79 characters max, truncated below in write) - img_name = os.path.splitext(os.path.basename(filename))[0] - img_name = img_name.encode("ascii", "ignore") + filename = os.path.basename(filename) + img_name = os.path.splitext(filename)[0].encode("ascii", "ignore") # Standard representation of pixel in the file colormap = 0 fp.write(struct.pack(">h", magic_number)) @@ -201,7 +204,10 @@ def _save(im, fp, filename): class SGI16Decoder(ImageFile.PyDecoder): _pulls_fd = True - def decode(self, buffer): + def decode(self, buffer: bytes) -> tuple[int, int]: + assert self.fd is not None + assert self.im is not None + rawmode, stride, orientation = self.args pagesize = self.state.xsize * self.state.ysize zsize = len(self.mode) diff --git a/src/PIL/SunImagePlugin.py b/src/PIL/SunImagePlugin.py index 11ce3dfef..4e098474a 100644 --- a/src/PIL/SunImagePlugin.py +++ b/src/PIL/SunImagePlugin.py @@ -21,7 +21,7 @@ from . import Image, ImageFile, ImagePalette from ._binary import i32be as i32 -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return len(prefix) >= 4 and i32(prefix) == 0x59A66A95 @@ -33,7 +33,7 @@ class SunImageFile(ImageFile.ImageFile): format = "SUN" format_description = "Sun Raster File" - def _open(self): + def _open(self) -> None: # The Sun Raster file header is 32 bytes in length # and has the following format: @@ -49,6 +49,8 @@ class SunImageFile(ImageFile.ImageFile): # DWORD ColorMapLength; /* Size of the color map in bytes */ # } SUNRASTER; + assert self.fp is not None + # HEAD s = self.fp.read(32) if not _accept(s): diff --git a/src/PIL/TarIO.py b/src/PIL/TarIO.py index c9923487d..7470663b4 100644 --- a/src/PIL/TarIO.py +++ b/src/PIL/TarIO.py @@ -21,7 +21,7 @@ from types import TracebackType from . import ContainerIO -class TarIO(ContainerIO.ContainerIO): +class TarIO(ContainerIO.ContainerIO[bytes]): """A file object that provides read access to a given member of a TAR file.""" def __init__(self, tarfile: str, file: str) -> None: diff --git a/src/PIL/TgaImagePlugin.py b/src/PIL/TgaImagePlugin.py index 65c7484f7..401a83f9f 100644 --- a/src/PIL/TgaImagePlugin.py +++ b/src/PIL/TgaImagePlugin.py @@ -18,6 +18,7 @@ from __future__ import annotations import warnings +from typing import IO from . import Image, ImageFile, ImagePalette from ._binary import i16le as i16 @@ -49,8 +50,10 @@ class TgaImageFile(ImageFile.ImageFile): format = "TGA" format_description = "Targa" - def _open(self): + def _open(self) -> None: # process header + assert self.fp is not None + s = self.fp.read(18) id_len = s[0] @@ -82,7 +85,7 @@ class TgaImageFile(ImageFile.ImageFile): elif depth == 16: self._mode = "LA" elif imagetype in (1, 9): - self._mode = "P" + self._mode = "P" if colormaptype else "L" elif imagetype in (2, 10): self._mode = "RGB" if depth == 32: @@ -125,6 +128,9 @@ class TgaImageFile(ImageFile.ImageFile): self.palette = ImagePalette.raw( "BGRA", b"\0" * 4 * start + self.fp.read(4 * size) ) + else: + msg = "unknown TGA map depth" + raise SyntaxError(msg) # setup tile descriptor try: @@ -151,8 +157,9 @@ class TgaImageFile(ImageFile.ImageFile): except KeyError: pass # cannot decode - def load_end(self): + def load_end(self) -> None: if self._flip_horizontally: + assert self.im is not None self.im = self.im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) @@ -171,7 +178,7 @@ SAVE = { } -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: try: rawmode, bits, colormaptype, imagetype = SAVE[im.mode] except KeyError as e: @@ -194,6 +201,7 @@ def _save(im, fp, filename): warnings.warn("id_section has been trimmed to 255 characters") if colormaptype: + assert im.im is not None palette = im.im.getpalette("RGB", "BGR") colormaplength, colormapentry = len(palette) // 3, 24 else: diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index fc242ca64..b59139f58 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -50,6 +50,7 @@ import warnings from collections.abc import MutableMapping from fractions import Fraction from numbers import Number, Rational +from typing import TYPE_CHECKING, Any, Callable from . import ExifTags, Image, ImageFile, ImageOps, ImagePalette, TiffTags from ._binary import i16be as i16 @@ -306,6 +307,13 @@ _load_dispatch = {} _write_dispatch = {} +def _delegate(op): + def delegate(self, *args): + return getattr(self._val, op)(*args) + + return delegate + + class IFDRational(Rational): """Implements a rational class where 0/0 is a legal value to match the in the wild use of exif rationals. @@ -391,12 +399,6 @@ class IFDRational(Rational): self._numerator = _numerator self._denominator = _denominator - def _delegate(op): - def delegate(self, *args): - return getattr(self._val, op)(*args) - - return delegate - """ a = ['add','radd', 'sub', 'rsub', 'mul', 'rmul', 'truediv', 'rtruediv', 'floordiv', 'rfloordiv', 'mod','rmod', 'pow','rpow', 'pos', 'neg', @@ -436,7 +438,50 @@ class IFDRational(Rational): __int__ = _delegate("__int__") -class ImageFileDirectory_v2(MutableMapping): +def _register_loader(idx, size): + def decorator(func): + from .TiffTags import TYPES + + if func.__name__.startswith("load_"): + TYPES[idx] = func.__name__[5:].replace("_", " ") + _load_dispatch[idx] = size, func # noqa: F821 + return func + + return decorator + + +def _register_writer(idx): + def decorator(func): + _write_dispatch[idx] = func # noqa: F821 + return func + + return decorator + + +def _register_basic(idx_fmt_name): + from .TiffTags import TYPES + + idx, fmt, name = idx_fmt_name + TYPES[idx] = name + size = struct.calcsize("=" + fmt) + _load_dispatch[idx] = ( # noqa: F821 + size, + lambda self, data, legacy_api=True: ( + self._unpack(f"{len(data) // size}{fmt}", data) + ), + ) + _write_dispatch[idx] = lambda self, *values: ( # noqa: F821 + b"".join(self._pack(fmt, value) for value in values) + ) + + +if TYPE_CHECKING: + _IFDv2Base = MutableMapping[int, Any] +else: + _IFDv2Base = MutableMapping + + +class ImageFileDirectory_v2(_IFDv2Base): """This class represents a TIFF tag directory. To speed things up, we don't decode tags unless they're asked for. @@ -497,6 +542,9 @@ class ImageFileDirectory_v2(MutableMapping): """ + _load_dispatch: dict[int, Callable[[ImageFileDirectory_v2, bytes, bool], Any]] = {} + _write_dispatch: dict[int, Callable[..., Any]] = {} + def __init__(self, ifh=b"II\052\0\0\0\0\0", prefix=None, group=None): """Initialize an ImageFileDirectory. @@ -531,7 +579,10 @@ class ImageFileDirectory_v2(MutableMapping): prefix = property(lambda self: self._prefix) offset = property(lambda self: self._offset) - legacy_api = property(lambda self: self._legacy_api) + + @property + def legacy_api(self): + return self._legacy_api @legacy_api.setter def legacy_api(self, value): @@ -674,40 +725,6 @@ class ImageFileDirectory_v2(MutableMapping): def _pack(self, fmt, *values): return struct.pack(self._endian + fmt, *values) - def _register_loader(idx, size): - def decorator(func): - from .TiffTags import TYPES - - if func.__name__.startswith("load_"): - TYPES[idx] = func.__name__[5:].replace("_", " ") - _load_dispatch[idx] = size, func # noqa: F821 - return func - - return decorator - - def _register_writer(idx): - def decorator(func): - _write_dispatch[idx] = func # noqa: F821 - return func - - return decorator - - def _register_basic(idx_fmt_name): - from .TiffTags import TYPES - - idx, fmt, name = idx_fmt_name - TYPES[idx] = name - size = struct.calcsize("=" + fmt) - _load_dispatch[idx] = ( # noqa: F821 - size, - lambda self, data, legacy_api=True: ( - self._unpack(f"{len(data) // size}{fmt}", data) - ), - ) - _write_dispatch[idx] = lambda self, *values: ( # noqa: F821 - b"".join(self._pack(fmt, value) for value in values) - ) - list( map( _register_basic, @@ -773,6 +790,8 @@ class ImageFileDirectory_v2(MutableMapping): @_register_writer(7) def write_undefined(self, value): + if isinstance(value, IFDRational): + value = int(value) if isinstance(value, int): value = str(value).encode("ascii", "replace") return value @@ -995,7 +1014,7 @@ class ImageFileDirectory_v1(ImageFileDirectory_v2): tagdata = property(lambda self: self._tagdata) # defined in ImageFileDirectory_v2 - tagtype: dict + tagtype: dict[int, int] """Dictionary of tag types""" @classmethod @@ -1704,25 +1723,27 @@ def _save(im, fp, filename): colormap += [0] * (256 - colors) ifd[COLORMAP] = colormap # data orientation - stride = len(bits) * ((im.size[0] * bits[0] + 7) // 8) - # aim for given strip size (64 KB by default) when using libtiff writer - if libtiff: - im_strip_size = encoderinfo.get("strip_size", STRIP_SIZE) - rows_per_strip = 1 if stride == 0 else min(im_strip_size // stride, im.size[1]) - # JPEG encoder expects multiple of 8 rows - if compression == "jpeg": - rows_per_strip = min(((rows_per_strip + 7) // 8) * 8, im.size[1]) - else: - rows_per_strip = im.size[1] - if rows_per_strip == 0: - rows_per_strip = 1 - strip_byte_counts = 1 if stride == 0 else stride * rows_per_strip - strips_per_image = (im.size[1] + rows_per_strip - 1) // rows_per_strip - ifd[ROWSPERSTRIP] = rows_per_strip + w, h = ifd[IMAGEWIDTH], ifd[IMAGELENGTH] + stride = len(bits) * ((w * bits[0] + 7) // 8) + if ROWSPERSTRIP not in ifd: + # aim for given strip size (64 KB by default) when using libtiff writer + if libtiff: + im_strip_size = encoderinfo.get("strip_size", STRIP_SIZE) + rows_per_strip = 1 if stride == 0 else min(im_strip_size // stride, h) + # JPEG encoder expects multiple of 8 rows + if compression == "jpeg": + rows_per_strip = min(((rows_per_strip + 7) // 8) * 8, h) + else: + rows_per_strip = h + if rows_per_strip == 0: + rows_per_strip = 1 + ifd[ROWSPERSTRIP] = rows_per_strip + strip_byte_counts = 1 if stride == 0 else stride * ifd[ROWSPERSTRIP] + strips_per_image = (h + ifd[ROWSPERSTRIP] - 1) // ifd[ROWSPERSTRIP] if strip_byte_counts >= 2**16: ifd.tagtype[STRIPBYTECOUNTS] = TiffTags.LONG ifd[STRIPBYTECOUNTS] = (strip_byte_counts,) * (strips_per_image - 1) + ( - stride * im.size[1] - strip_byte_counts * (strips_per_image - 1), + stride * h - strip_byte_counts * (strips_per_image - 1), ) ifd[STRIPOFFSETS] = tuple( range(0, strip_byte_counts * strips_per_image, strip_byte_counts) @@ -1833,11 +1854,11 @@ def _save(im, fp, filename): tags = list(atts.items()) tags.sort() a = (rawmode, compression, _fp, filename, tags, types) - e = Image._getencoder(im.mode, "libtiff", a, encoderconfig) - e.setimage(im.im, (0, 0) + im.size) + encoder = Image._getencoder(im.mode, "libtiff", a, encoderconfig) + encoder.setimage(im.im, (0, 0) + im.size) while True: # undone, change to self.decodermaxblock: - errcode, data = e.encode(16 * 1024)[1:] + errcode, data = encoder.encode(16 * 1024)[1:] if not _fp: fp.write(data) if errcode: diff --git a/src/PIL/TiffTags.py b/src/PIL/TiffTags.py index 88ff2f4fc..b94193931 100644 --- a/src/PIL/TiffTags.py +++ b/src/PIL/TiffTags.py @@ -22,7 +22,7 @@ from collections import namedtuple class TagInfo(namedtuple("_TagInfo", "value name type length enum")): - __slots__ = [] + __slots__: list[str] = [] def __new__(cls, value=None, name="unknown", type=None, length=None, enum=None): return super().__new__(cls, value, name, type, length, enum or {}) @@ -437,7 +437,7 @@ _populate() ## # Map type numbers to type names -- defined in ImageFileDirectory. -TYPES = {} +TYPES: dict[int, str] = {} # # These tags are handled by default in libtiff, without diff --git a/src/PIL/XVThumbImagePlugin.py b/src/PIL/XVThumbImagePlugin.py index 47ba1c548..c84adaca2 100644 --- a/src/PIL/XVThumbImagePlugin.py +++ b/src/PIL/XVThumbImagePlugin.py @@ -33,7 +33,7 @@ for r in range(8): ) -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix[:6] == _MAGIC @@ -45,8 +45,10 @@ class XVThumbImageFile(ImageFile.ImageFile): format = "XVThumb" format_description = "XV thumbnail image" - def _open(self): + def _open(self) -> None: # check magic + assert self.fp is not None + if not _accept(self.fp.read(6)): msg = "not an XV thumbnail file" raise SyntaxError(msg) diff --git a/src/PIL/XbmImagePlugin.py b/src/PIL/XbmImagePlugin.py index 566acbfe5..eee727436 100644 --- a/src/PIL/XbmImagePlugin.py +++ b/src/PIL/XbmImagePlugin.py @@ -21,6 +21,7 @@ from __future__ import annotations import re +from typing import IO from . import Image, ImageFile @@ -36,7 +37,7 @@ xbm_head = re.compile( ) -def _accept(prefix): +def _accept(prefix: bytes) -> bool: return prefix.lstrip()[:7] == b"#define" @@ -48,7 +49,9 @@ class XbmImageFile(ImageFile.ImageFile): format = "XBM" format_description = "X11 Bitmap" - def _open(self): + def _open(self) -> None: + assert self.fp is not None + m = xbm_head.match(self.fp.read(512)) if not m: @@ -67,7 +70,7 @@ class XbmImageFile(ImageFile.ImageFile): self.tile = [("xbm", (0, 0) + self.size, m.end(), None)] -def _save(im, fp, filename): +def _save(im: Image.Image, fp: IO[bytes], filename: str) -> None: if im.mode != "1": msg = f"cannot write mode {im.mode} as XBM" raise OSError(msg) diff --git a/src/PIL/XpmImagePlugin.py b/src/PIL/XpmImagePlugin.py index bf73c9bef..3125f8d52 100644 --- a/src/PIL/XpmImagePlugin.py +++ b/src/PIL/XpmImagePlugin.py @@ -103,7 +103,7 @@ class XpmImageFile(ImageFile.ImageFile): self.tile = [("raw", (0, 0) + self.size, self.fp.tell(), ("P", 0, 1))] - def load_read(self, bytes): + def load_read(self, read_bytes): # # load all image data in one chunk diff --git a/src/PIL/__init__.py b/src/PIL/__init__.py index 3fcac8643..63a45769b 100644 --- a/src/PIL/__init__.py +++ b/src/PIL/__init__.py @@ -12,6 +12,7 @@ Use PIL.__version__ for this Pillow version. ;-) """ + from __future__ import annotations from . import _version diff --git a/src/PIL/_binary.py b/src/PIL/_binary.py index c60c9cec1..4594ccce3 100644 --- a/src/PIL/_binary.py +++ b/src/PIL/_binary.py @@ -18,16 +18,16 @@ from __future__ import annotations from struct import pack, unpack_from -def i8(c): - return c if c.__class__ is int else c[0] +def i8(c: bytes) -> int: + return c[0] -def o8(i): +def o8(i: int) -> bytes: return bytes((i & 255,)) # Input, le = little endian, be = big endian -def i16le(c, o=0): +def i16le(c: bytes, o: int = 0) -> int: """ Converts a 2-bytes (16 bits) string to an unsigned integer. @@ -37,7 +37,7 @@ def i16le(c, o=0): return unpack_from(" int: """ Converts a 2-bytes (16 bits) string to a signed integer. @@ -47,7 +47,7 @@ def si16le(c, o=0): return unpack_from(" int: """ Converts a 2-bytes (16 bits) string to a signed integer, big endian. @@ -57,7 +57,7 @@ def si16be(c, o=0): return unpack_from(">h", c, o)[0] -def i32le(c, o=0): +def i32le(c: bytes, o: int = 0) -> int: """ Converts a 4-bytes (32 bits) string to an unsigned integer. @@ -67,7 +67,7 @@ def i32le(c, o=0): return unpack_from(" int: """ Converts a 4-bytes (32 bits) string to a signed integer. @@ -77,26 +77,36 @@ def si32le(c, o=0): return unpack_from(" int: + """ + Converts a 4-bytes (32 bits) string to a signed integer, big endian. + + :param c: string containing bytes to convert + :param o: offset of bytes to convert in string + """ + return unpack_from(">i", c, o)[0] + + +def i16be(c: bytes, o: int = 0) -> int: return unpack_from(">H", c, o)[0] -def i32be(c, o=0): +def i32be(c: bytes, o: int = 0) -> int: return unpack_from(">I", c, o)[0] # Output, le = little endian, be = big endian -def o16le(i): +def o16le(i: int) -> bytes: return pack(" bytes: return pack(" bytes: return pack(">H", i) -def o32be(i): +def o32be(i: int) -> bytes: return pack(">I", i) diff --git a/src/PIL/_imaging.pyi b/src/PIL/_imaging.pyi new file mode 100644 index 000000000..b0235555d --- /dev/null +++ b/src/PIL/_imaging.pyi @@ -0,0 +1,5 @@ +from __future__ import annotations + +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/_imagingcms.pyi b/src/PIL/_imagingcms.pyi new file mode 100644 index 000000000..b0235555d --- /dev/null +++ b/src/PIL/_imagingcms.pyi @@ -0,0 +1,5 @@ +from __future__ import annotations + +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/_imagingft.pyi b/src/PIL/_imagingft.pyi new file mode 100644 index 000000000..b0235555d --- /dev/null +++ b/src/PIL/_imagingft.pyi @@ -0,0 +1,5 @@ +from __future__ import annotations + +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/_imagingmath.pyi b/src/PIL/_imagingmath.pyi new file mode 100644 index 000000000..b0235555d --- /dev/null +++ b/src/PIL/_imagingmath.pyi @@ -0,0 +1,5 @@ +from __future__ import annotations + +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/_imagingmorph.pyi b/src/PIL/_imagingmorph.pyi new file mode 100644 index 000000000..b0235555d --- /dev/null +++ b/src/PIL/_imagingmorph.pyi @@ -0,0 +1,5 @@ +from __future__ import annotations + +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/_tkinter_finder.py b/src/PIL/_tkinter_finder.py index 03a6eba44..beddfb062 100644 --- a/src/PIL/_tkinter_finder.py +++ b/src/PIL/_tkinter_finder.py @@ -1,10 +1,12 @@ """ Find compiled module linking to Tcl / Tk libraries """ + from __future__ import annotations import sys import tkinter -from tkinter import _tkinter as tk + +tk = getattr(tkinter, "_tkinter") try: if hasattr(sys, "pypy_find_executable"): diff --git a/src/PIL/_typing.py b/src/PIL/_typing.py new file mode 100644 index 000000000..7075e8672 --- /dev/null +++ b/src/PIL/_typing.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import os +import sys +from typing import Protocol, Sequence, TypeVar, Union + +if sys.version_info >= (3, 10): + from typing import TypeGuard +else: + try: + from typing_extensions import TypeGuard + except ImportError: + from typing import Any + + class TypeGuard: # type: ignore[no-redef] + def __class_getitem__(cls, item: Any) -> type[bool]: + return bool + + +Coords = Union[Sequence[float], Sequence[Sequence[float]]] + + +_T_co = TypeVar("_T_co", covariant=True) + + +class SupportsRead(Protocol[_T_co]): + def read(self, __length: int = ...) -> _T_co: ... + + +StrOrBytesPath = Union[str, bytes, "os.PathLike[str]", "os.PathLike[bytes]"] + + +__all__ = ["TypeGuard", "StrOrBytesPath", "SupportsRead"] diff --git a/src/PIL/_util.py b/src/PIL/_util.py index 4634d335b..6bc762816 100644 --- a/src/PIL/_util.py +++ b/src/PIL/_util.py @@ -1,21 +1,31 @@ from __future__ import annotations import os -from pathlib import Path +from typing import Any, NoReturn + +from ._typing import StrOrBytesPath, TypeGuard -def is_path(f): - return isinstance(f, (bytes, str, Path)) +def is_path(f: Any) -> TypeGuard[StrOrBytesPath]: + return isinstance(f, (bytes, str, os.PathLike)) -def is_directory(f): +def is_directory(f: Any) -> TypeGuard[StrOrBytesPath]: """Checks if an object is a string, and that it points to a directory.""" return is_path(f) and os.path.isdir(f) class DeferredError: - def __init__(self, ex): + def __init__(self, ex: BaseException): self.ex = ex - def __getattr__(self, elt): + def __getattr__(self, elt: str) -> NoReturn: raise self.ex + + @staticmethod + def new(ex: BaseException) -> Any: + """ + Creates an object that raises the wrapped exception ``ex`` when used, + and casts it to :py:obj:`~typing.Any` type. + """ + return DeferredError(ex) diff --git a/src/PIL/_version.py b/src/PIL/_version.py index 7d994caf4..0568943b5 100644 --- a/src/PIL/_version.py +++ b/src/PIL/_version.py @@ -1,4 +1,4 @@ # Master version for Pillow from __future__ import annotations -__version__ = "10.2.0.dev0" +__version__ = "10.3.0.dev0" diff --git a/src/PIL/_webp.pyi b/src/PIL/_webp.pyi new file mode 100644 index 000000000..b0235555d --- /dev/null +++ b/src/PIL/_webp.pyi @@ -0,0 +1,5 @@ +from __future__ import annotations + +from typing import Any + +def __getattr__(name: str) -> Any: ... diff --git a/src/PIL/py.typed b/src/PIL/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/src/_imaging.c b/src/_imaging.c index 2270c77fe..59f80a354 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2649,6 +2649,26 @@ _font_new(PyObject *self_, PyObject *args) { self->glyphs[i].sy0 = S16(B16(glyphdata, 14)); self->glyphs[i].sx1 = S16(B16(glyphdata, 16)); self->glyphs[i].sy1 = S16(B16(glyphdata, 18)); + + // Do not allow glyphs to extend beyond bitmap image + // Helps prevent DOS by stopping cropped images being larger than the original + if (self->glyphs[i].sx0 < 0) { + self->glyphs[i].dx0 -= self->glyphs[i].sx0; + self->glyphs[i].sx0 = 0; + } + if (self->glyphs[i].sy0 < 0) { + self->glyphs[i].dy0 -= self->glyphs[i].sy0; + self->glyphs[i].sy0 = 0; + } + if (self->glyphs[i].sx1 > self->bitmap->xsize) { + self->glyphs[i].dx1 -= self->glyphs[i].sx1 - self->bitmap->xsize; + self->glyphs[i].sx1 = self->bitmap->xsize; + } + if (self->glyphs[i].sy1 > self->bitmap->ysize) { + self->glyphs[i].dy1 -= self->glyphs[i].sy1 - self->bitmap->ysize; + self->glyphs[i].sy1 = self->bitmap->ysize; + } + if (self->glyphs[i].dy0 < y0) { y0 = self->glyphs[i].dy0; } @@ -2721,7 +2741,7 @@ _font_text_asBytes(PyObject *encoded_string, unsigned char **text) { static PyObject * _font_getmask(ImagingFontObject *self, PyObject *args) { Imaging im; - Imaging bitmap; + Imaging bitmap = NULL; int x, b; int i = 0; int status; @@ -2730,7 +2750,7 @@ _font_getmask(ImagingFontObject *self, PyObject *args) { PyObject *encoded_string; unsigned char *text; - char *mode = ""; + char *mode; if (!PyArg_ParseTuple(args, "O|s:getmask", &encoded_string, &mode)) { return NULL; @@ -2753,10 +2773,13 @@ _font_getmask(ImagingFontObject *self, PyObject *args) { b = self->baseline; for (x = 0; text[i]; i++) { glyph = &self->glyphs[text[i]]; - bitmap = - ImagingCrop(self->bitmap, glyph->sx0, glyph->sy0, glyph->sx1, glyph->sy1); - if (!bitmap) { - goto failed; + if (i == 0 || text[i] != text[i - 1]) { + ImagingDelete(bitmap); + bitmap = + ImagingCrop(self->bitmap, glyph->sx0, glyph->sy0, glyph->sx1, glyph->sy1); + if (!bitmap) { + goto failed; + } } status = ImagingPaste( im, @@ -2766,17 +2789,18 @@ _font_getmask(ImagingFontObject *self, PyObject *args) { glyph->dy0 + b, glyph->dx1 + x, glyph->dy1 + b); - ImagingDelete(bitmap); if (status < 0) { goto failed; } x = x + glyph->dx; b = b + glyph->dy; } + ImagingDelete(bitmap); free(text); return PyImagingNew(im); failed: + ImagingDelete(bitmap); free(text); ImagingDelete(im); Py_RETURN_NONE; diff --git a/src/_imagingft.c b/src/_imagingft.c index 68c66ac2c..6e24fcf95 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -880,7 +880,7 @@ font_render(FontObject *self, PyObject *args) { image = PyObject_CallFunction(fill, "ii", width, height); if (image == Py_None) { PyMem_Del(glyph_info); - return Py_BuildValue("ii", 0, 0); + return Py_BuildValue("N(ii)", image, 0, 0); } else if (image == NULL) { PyMem_Del(glyph_info); return NULL; @@ -894,7 +894,7 @@ font_render(FontObject *self, PyObject *args) { y_offset -= stroke_width; if (count == 0 || width == 0 || height == 0) { PyMem_Del(glyph_info); - return Py_BuildValue("ii", x_offset, y_offset); + return Py_BuildValue("N(ii)", image, x_offset, y_offset); } if (stroke_width) { @@ -1130,18 +1130,12 @@ font_render(FontObject *self, PyObject *args) { if (bitmap_converted_ready) { FT_Bitmap_Done(library, &bitmap_converted); } - Py_DECREF(image); FT_Stroker_Done(stroker); PyMem_Del(glyph_info); - return Py_BuildValue("ii", x_offset, y_offset); + return Py_BuildValue("N(ii)", image, x_offset, y_offset); glyph_error: - if (im->destroy) { - im->destroy(im); - } - if (im->image) { - free(im->image); - } + Py_DECREF(image); if (stroker != NULL) { FT_Done_Glyph(glyph); } diff --git a/src/_webp.c b/src/_webp.c index a1b4dbc1a..47592547c 100644 --- a/src/_webp.c +++ b/src/_webp.c @@ -448,11 +448,16 @@ PyObject * _anim_decoder_get_next(PyObject *self) { uint8_t *buf; int timestamp; + int ok; PyObject *bytes; PyObject *ret; + ImagingSectionCookie cookie; WebPAnimDecoderObject *decp = (WebPAnimDecoderObject *)self; - if (!WebPAnimDecoderGetNext(decp->dec, &buf, ×tamp)) { + ImagingSectionEnter(&cookie); + ok = WebPAnimDecoderGetNext(decp->dec, &buf, ×tamp); + ImagingSectionLeave(&cookie); + if (!ok) { PyErr_SetString(PyExc_OSError, "failed to read next frame"); return NULL; } diff --git a/src/encode.c b/src/encode.c index 4664ad0f3..c7dd51015 100644 --- a/src/encode.c +++ b/src/encode.c @@ -1042,6 +1042,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { Py_ssize_t progressive = 0; Py_ssize_t smooth = 0; Py_ssize_t optimize = 0; + int keep_rgb = 0; Py_ssize_t streamtype = 0; /* 0=interchange, 1=tables only, 2=image only */ Py_ssize_t xdpi = 0, ydpi = 0; Py_ssize_t subsampling = -1; /* -1=default, 0=none, 1=medium, 2=high */ @@ -1059,13 +1060,14 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { if (!PyArg_ParseTuple( args, - "ss|nnnnnnnnnnOz#y#y#", + "ss|nnnnpnnnnnnOz#y#y#", &mode, &rawmode, &quality, &progressive, &smooth, &optimize, + &keep_rgb, &streamtype, &xdpi, &ydpi, @@ -1150,6 +1152,7 @@ PyImaging_JpegEncoderNew(PyObject *self, PyObject *args) { strncpy(((JPEGENCODERSTATE *)encoder->state.context)->rawmode, rawmode, 8); + ((JPEGENCODERSTATE *)encoder->state.context)->keep_rgb = keep_rgb; ((JPEGENCODERSTATE *)encoder->state.context)->quality = quality; ((JPEGENCODERSTATE *)encoder->state.context)->qtables = qarrays; ((JPEGENCODERSTATE *)encoder->state.context)->qtablesLen = qtablesLen; diff --git a/src/libImaging/Convert.c b/src/libImaging/Convert.c index 99d2a4ada..5cc39cd00 100644 --- a/src/libImaging/Convert.c +++ b/src/libImaging/Convert.c @@ -510,7 +510,7 @@ rgbT2rgba(UINT8 *out, int xsize, int r, int g, int b) { UINT32 trns = ((r & 0xff) << 24) | ((g & 0xff) << 16) | ((b & 0xff) << 8) | 0xff; UINT32 repl = trns & 0xffffff00; #else - UINT32 trns = (0xff << 24) | ((b & 0xff) << 16) | ((g & 0xff) << 8) | (r & 0xff); + UINT32 trns = (0xffU << 24) | ((b & 0xff) << 16) | ((g & 0xff) << 8) | (r & 0xff); UINT32 repl = trns & 0x00ffffff; #endif @@ -878,6 +878,18 @@ I16B_L(UINT8 *out, const UINT8 *in, int xsize) { } } +static void +I16_RGB(UINT8 *out, const UINT8 *in, int xsize) { + int x; + for (x = 0; x < xsize; x++, in += 2) { + UINT8 v = in[1] == 0 ? in[0] : 255; + *out++ = v; + *out++ = v; + *out++ = v; + *out++ = 255; + } +} + static struct { const char *from; const char *to; @@ -978,6 +990,7 @@ static struct { {"I", "I;16", I_I16L}, {"I;16", "I", I16L_I}, + {"I;16", "RGB", I16_RGB}, {"L", "I;16", L_I16L}, {"I;16", "L", I16L_L}, @@ -1678,6 +1691,7 @@ ImagingConvertTransparent(Imaging imIn, const char *mode, int r, int g, int b) { convert = rgb2rgba; } else if ((strcmp(imIn->mode, "1") == 0 || strcmp(imIn->mode, "I") == 0 || + strcmp(imIn->mode, "I;16") == 0 || strcmp(imIn->mode, "L") == 0 ) && ( strcmp(mode, "RGBA") == 0 || @@ -1687,6 +1701,8 @@ ImagingConvertTransparent(Imaging imIn, const char *mode, int r, int g, int b) { convert = bit2rgb; } else if (strcmp(imIn->mode, "I") == 0) { convert = i2rgb; + } else if (strcmp(imIn->mode, "I;16") == 0) { + convert = I16_RGB; } else { convert = l2rgb; } diff --git a/src/libImaging/GifEncode.c b/src/libImaging/GifEncode.c index f23245405..e37301df7 100644 --- a/src/libImaging/GifEncode.c +++ b/src/libImaging/GifEncode.c @@ -105,7 +105,7 @@ encode_loop: st->head = st->codes[st->probe] >> 20; goto encode_loop; } else { - /* Reprobe decrement must be nonzero and relatively prime to table + /* Reprobe decrement must be non-zero and relatively prime to table * size. So, any odd positive number for power-of-2 size. */ if ((st->probe -= ((st->tail << 2) | 1)) < 0) { st->probe += TABLE_SIZE; diff --git a/src/libImaging/Jpeg.h b/src/libImaging/Jpeg.h index 5cc74e69b..7cdba9022 100644 --- a/src/libImaging/Jpeg.h +++ b/src/libImaging/Jpeg.h @@ -74,6 +74,9 @@ typedef struct { /* Optimize Huffman tables (slow) */ int optimize; + /* Disable automatic conversion of RGB images to YCbCr if non-zero */ + int keep_rgb; + /* Stream type (0=full, 1=tables only, 2=image only) */ int streamtype; diff --git a/src/libImaging/JpegEncode.c b/src/libImaging/JpegEncode.c index 9da830b18..00f3d5f74 100644 --- a/src/libImaging/JpegEncode.c +++ b/src/libImaging/JpegEncode.c @@ -137,6 +137,30 @@ ImagingJpegEncode(Imaging im, ImagingCodecState state, UINT8 *buf, int bytes) { /* Compressor configuration */ jpeg_set_defaults(&context->cinfo); + /* Prevent RGB -> YCbCr conversion */ + if (context->keep_rgb) { + switch (context->cinfo.in_color_space) { + case JCS_RGB: +#ifdef JCS_EXTENSIONS + case JCS_EXT_RGBX: +#endif + switch (context->subsampling) { + case -1: /* Default */ + case 0: /* No subsampling */ + break; + default: + /* Would subsample the green and blue + channels, which doesn't make sense */ + state->errcode = IMAGING_CODEC_CONFIG; + return -1; + } + jpeg_set_colorspace(&context->cinfo, JCS_RGB); + break; + default: + break; + } + } + /* Use custom quantization tables */ if (context->qtables) { int i; diff --git a/tox.ini b/tox.ini index 034d89372..85a2020d6 100644 --- a/tox.ini +++ b/tox.ini @@ -33,7 +33,16 @@ commands = [testenv:mypy] skip_install = true deps = - mypy==1.7.1 + -r .ci/requirements-mypy.txt + IceSpringPySideStubs-PyQt6 + IceSpringPySideStubs-PySide6 + ipython numpy + packaging + types-cffi + types-defusedxml + types-olefile +extras = + typing commands = mypy src {posargs} diff --git a/winbuild/build.rst b/winbuild/build.rst index a8e4ebaa6..cd3b559e7 100644 --- a/winbuild/build.rst +++ b/winbuild/build.rst @@ -27,7 +27,7 @@ Download and install: * `Ninja `_ (optional, use ``--nmake`` if not available; bundled in Visual Studio CMake component) -* x86/x64: `Netwide Assembler (NASM) `_ +* x86/AMD64: `Netwide Assembler (NASM) `_ Any version of Visual Studio 2017 or newer should be supported, including Visual Studio 2017 Community, or Build Tools for Visual Studio 2019. @@ -42,7 +42,7 @@ Run ``build_prepare.py`` to configure the build:: usage: winbuild\build_prepare.py [-h] [-v] [-d PILLOW_BUILD] [--depends PILLOW_DEPS] - [--architecture {x86,x64,ARM64}] [--nmake] + [--architecture {x86,AMD64,ARM64}] [--nmake] [--no-imagequant] [--no-fribidi] Download and generate build scripts for Pillow dependencies. @@ -55,7 +55,7 @@ Run ``build_prepare.py`` to configure the build:: --depends PILLOW_DEPS directory used to store cached dependencies (default: 'winbuild\depends') - --architecture {x86,x64,ARM64} + --architecture {x86,AMD64,ARM64} build architecture (default: same as host Python) --nmake build dependencies using NMake instead of Ninja --no-imagequant skip GPL-licensed optional dependency libimagequant diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index 8e3757ca8..5e144d598 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -87,7 +87,7 @@ def cmd_msbuild( file: str, configuration: str = "Release", target: str = "Build", - platform: str = "{msbuild_arch}", + plat: str = "{msbuild_arch}", ) -> str: return " ".join( [ @@ -95,7 +95,7 @@ def cmd_msbuild( f"{file}", f'/t:"{target}"', f'/p:Configuration="{configuration}"', - f"/p:Platform={platform}", + f"/p:Platform={plat}", "/m", ] ) @@ -105,17 +105,36 @@ SF_PROJECTS = "https://sourceforge.net/projects" ARCHITECTURES = { "x86": {"vcvars_arch": "x86", "msbuild_arch": "Win32"}, - "x64": {"vcvars_arch": "x86_amd64", "msbuild_arch": "x64"}, + "AMD64": {"vcvars_arch": "x86_amd64", "msbuild_arch": "x64"}, "ARM64": {"vcvars_arch": "x86_arm64", "msbuild_arch": "ARM64"}, } +V = { + "BROTLI": "1.1.0", + "FREETYPE": "2.13.2", + "FRIBIDI": "1.0.13", + "HARFBUZZ": "8.3.0", + "JPEGTURBO": "3.0.2", + "LCMS2": "2.16", + "LIBPNG": "1.6.43", + "LIBWEBP": "1.3.2", + "OPENJPEG": "2.5.2", + "TIFF": "4.6.0", + "XZ": "5.4.5", + "ZLIB": "1.3.1", +} +V["LIBPNG_DOTLESS"] = V["LIBPNG"].replace(".", "") +V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2]) +V["ZLIB_DOTLESS"] = V["ZLIB"].replace(".", "") + + # dependencies, listed in order of compilation DEPS = { "libjpeg": { - "url": SF_PROJECTS - + "/libjpeg-turbo/files/3.0.1/libjpeg-turbo-3.0.1.tar.gz/download", - "filename": "libjpeg-turbo-3.0.1.tar.gz", - "dir": "libjpeg-turbo-3.0.1", + "url": f"{SF_PROJECTS}/libjpeg-turbo/files/{V['JPEGTURBO']}/" + f"libjpeg-turbo-{V['JPEGTURBO']}.tar.gz/download", + "filename": f"libjpeg-turbo-{V['JPEGTURBO']}.tar.gz", + "dir": f"libjpeg-turbo-{V['JPEGTURBO']}", "license": ["README.ijg", "LICENSE.md"], "license_pattern": ( "(LEGAL ISSUES\n============\n\n.+?)\n\nREFERENCES\n==========" @@ -143,9 +162,9 @@ DEPS = { "bins": ["cjpeg.exe", "djpeg.exe"], }, "zlib": { - "url": "https://zlib.net/zlib13.zip", - "filename": "zlib13.zip", - "dir": "zlib-1.3", + "url": f"https://zlib.net/zlib{V['ZLIB_DOTLESS']}.zip", + "filename": f"zlib{V['ZLIB_DOTLESS']}.zip", + "dir": f"zlib-{V['ZLIB']}", "license": "README", "license_pattern": "Copyright notice:\n\n(.+)$", "build": [ @@ -157,9 +176,9 @@ DEPS = { "libs": [r"*.lib"], }, "xz": { - "url": SF_PROJECTS + "/lzmautils/files/xz-5.4.5.tar.gz/download", - "filename": "xz-5.4.5.tar.gz", - "dir": "xz-5.4.5", + "url": f"{SF_PROJECTS}/lzmautils/files/xz-{V['XZ']}.tar.gz/download", + "filename": f"xz-{V['XZ']}.tar.gz", + "dir": f"xz-{V['XZ']}", "license": "COPYING", "build": [ *cmds_cmake("liblzma", "-DBUILD_SHARED_LIBS:BOOL=OFF"), @@ -170,32 +189,31 @@ DEPS = { "libs": [r"liblzma.lib"], }, "libwebp": { - "url": "http://downloads.webmproject.org/releases/webp/libwebp-1.3.2.tar.gz", - "filename": "libwebp-1.3.2.tar.gz", - "dir": "libwebp-1.3.2", + "url": f"http://downloads.webmproject.org/releases/webp/libwebp-{V['LIBWEBP']}.tar.gz", + "filename": f"libwebp-{V['LIBWEBP']}.tar.gz", + "dir": f"libwebp-{V['LIBWEBP']}", "license": "COPYING", + "patch": { + r"src\enc\picture_csp_enc.c": { + # link against libsharpyuv.lib + '#include "sharpyuv/sharpyuv.h"': '#include "sharpyuv/sharpyuv.h"\n#pragma comment(lib, "libsharpyuv.lib")', # noqa: E501 + } + }, "build": [ - cmd_rmdir(r"output\release-static"), # clean - cmd_nmake( - "Makefile.vc", - "all", - [ - "CFG=release-static", - "RTLIBCFG=dynamic", - "OBJDIR=output", - "ARCH={architecture}", - "LIBWEBP_BASENAME=webp", - ], + *cmds_cmake( + "webp webpdemux webpmux", + "-DBUILD_SHARED_LIBS:BOOL=OFF", + "-DWEBP_LINK_STATIC:BOOL=OFF", ), cmd_mkdir(r"{inc_dir}\webp"), cmd_copy(r"src\webp\*.h", r"{inc_dir}\webp"), ], - "libs": [r"output\release-static\{architecture}\lib\*.lib"], + "libs": [r"libsharpyuv.lib", r"libwebp*.lib"], }, "libtiff": { - "url": "https://download.osgeo.org/libtiff/tiff-4.6.0.tar.gz", - "filename": "tiff-4.6.0.tar.gz", - "dir": "tiff-4.6.0", + "url": f"https://download.osgeo.org/libtiff/tiff-{V['TIFF']}.tar.gz", + "filename": f"tiff-{V['TIFF']}.tar.gz", + "dir": f"tiff-{V['TIFF']}", "license": "LICENSE.md", "patch": { r"libtiff\tif_lzma.c": { @@ -203,8 +221,8 @@ DEPS = { "#ifdef LZMA_SUPPORT": '#ifdef LZMA_SUPPORT\n#pragma comment(lib, "liblzma.lib")', # noqa: E501 }, r"libtiff\tif_webp.c": { - # link against webp.lib - "#ifdef WEBP_SUPPORT": '#ifdef WEBP_SUPPORT\n#pragma comment(lib, "webp.lib")', # noqa: E501 + # link against libwebp.lib + "#ifdef WEBP_SUPPORT": '#ifdef WEBP_SUPPORT\n#pragma comment(lib, "libwebp.lib")', # noqa: E501 }, r"test\CMakeLists.txt": { "add_executable(test_write_read_tags ../placeholder.h)": "", @@ -217,6 +235,7 @@ DEPS = { *cmds_cmake( "tiff", "-DBUILD_SHARED_LIBS:BOOL=OFF", + "-DWebP_LIBRARY=libwebp", '-DCMAKE_C_FLAGS="-nologo -DLZMA_API_STATIC"', ) ], @@ -224,21 +243,24 @@ DEPS = { "libs": [r"libtiff\*.lib"], }, "libpng": { - "url": SF_PROJECTS + "/libpng/files/libpng16/1.6.39/lpng1639.zip/download", - "filename": "lpng1639.zip", - "dir": "lpng1639", + "url": f"{SF_PROJECTS}/libpng/files/libpng{V['LIBPNG_XY']}/{V['LIBPNG']}/" + f"lpng{V['LIBPNG_DOTLESS']}.zip/download", + "filename": f"lpng{V['LIBPNG_DOTLESS']}.zip", + "dir": f"lpng{V['LIBPNG_DOTLESS']}", "license": "LICENSE", "build": [ *cmds_cmake("png_static", "-DPNG_SHARED:BOOL=OFF", "-DPNG_TESTS:BOOL=OFF"), - cmd_copy("libpng16_static.lib", "libpng16.lib"), + cmd_copy( + f"libpng{V['LIBPNG_XY']}_static.lib", f"libpng{V['LIBPNG_XY']}.lib" + ), ], "headers": [r"png*.h"], - "libs": [r"libpng16.lib"], + "libs": [f"libpng{V['LIBPNG_XY']}.lib"], }, "brotli": { - "url": "https://github.com/google/brotli/archive/refs/tags/v1.1.0.tar.gz", - "filename": "brotli-1.1.0.tar.gz", - "dir": "brotli-1.1.0", + "url": f"https://github.com/google/brotli/archive/refs/tags/v{V['BROTLI']}.tar.gz", + "filename": f"brotli-{V['BROTLI']}.tar.gz", + "dir": f"brotli-{V['BROTLI']}", "license": "LICENSE", "build": [ *cmds_cmake(("brotlicommon", "brotlidec"), "-DBUILD_SHARED_LIBS:BOOL=OFF"), @@ -247,9 +269,9 @@ DEPS = { "libs": ["*.lib"], }, "freetype": { - "url": "https://download.savannah.gnu.org/releases/freetype/freetype-2.13.2.tar.gz", - "filename": "freetype-2.13.2.tar.gz", - "dir": "freetype-2.13.2", + "url": f"https://download.savannah.gnu.org/releases/freetype/freetype-{V['FREETYPE']}.tar.gz", + "filename": f"freetype-{V['FREETYPE']}.tar.gz", + "dir": f"freetype-{V['FREETYPE']}", "license": ["LICENSE.TXT", r"docs\FTL.TXT", r"docs\GPLv2.TXT"], "patch": { r"builds\windows\vc2010\freetype.vcxproj": { @@ -262,7 +284,7 @@ DEPS = { "": "FT_CONFIG_OPTION_SYSTEM_ZLIB;FT_CONFIG_OPTION_USE_PNG;FT_CONFIG_OPTION_USE_HARFBUZZ;FT_CONFIG_OPTION_USE_BROTLI", # noqa: E501 "": r"{dir_harfbuzz}\src;{inc_dir}", # noqa: E501 "": "{lib_dir}", # noqa: E501 - "": "zlib.lib;libpng16.lib;brotlicommon.lib;brotlidec.lib", # noqa: E501 + "": f"zlib.lib;libpng{V['LIBPNG_XY']}.lib;brotlicommon.lib;brotlidec.lib", # noqa: E501 }, r"src/autofit/afshaper.c": { # link against harfbuzz.lib @@ -282,9 +304,9 @@ DEPS = { "libs": [r"objs\{msbuild_arch}\Release Static\freetype.lib"], }, "lcms2": { - "url": SF_PROJECTS + "/lcms/files/lcms/2.16/lcms2-2.16.tar.gz/download", - "filename": "lcms2-2.16.tar.gz", - "dir": "lcms2-2.16", + "url": f"{SF_PROJECTS}/lcms/files/lcms/{V['LCMS2']}/lcms2-{V['LCMS2']}.tar.gz/download", # noqa: E501 + "filename": f"lcms2-{V['LCMS2']}.tar.gz", + "dir": f"lcms2-{V['LCMS2']}", "license": "LICENSE", "patch": { r"Projects\VC2022\lcms2_static\lcms2_static.vcxproj": { @@ -308,21 +330,16 @@ DEPS = { "libs": [r"Lib\MS\*.lib"], }, "openjpeg": { - "url": "https://github.com/uclouvain/openjpeg/archive/v2.5.0.tar.gz", - "filename": "openjpeg-2.5.0.tar.gz", - "dir": "openjpeg-2.5.0", + "url": f"https://github.com/uclouvain/openjpeg/archive/v{V['OPENJPEG']}.tar.gz", + "filename": f"openjpeg-{V['OPENJPEG']}.tar.gz", + "dir": f"openjpeg-{V['OPENJPEG']}", "license": "LICENSE", - "patch": { - r"src\lib\openjp2\ht_dec.c": { - "#ifdef OPJ_COMPILER_MSVC\n return (OPJ_UINT32)__popcnt(val);": "#if defined(OPJ_COMPILER_MSVC) && (defined(_M_IX86) || defined(_M_AMD64))\n return (OPJ_UINT32)__popcnt(val);", # noqa: E501 - } - }, "build": [ *cmds_cmake( "openjp2", "-DBUILD_CODEC:BOOL=OFF", "-DBUILD_SHARED_LIBS:BOOL=OFF" ), - cmd_mkdir(r"{inc_dir}\openjpeg-2.5.0"), - cmd_copy(r"src\lib\openjp2\*.h", r"{inc_dir}\openjpeg-2.5.0"), + cmd_mkdir(rf"{{inc_dir}}\openjpeg-{V['OPENJPEG']}"), + cmd_copy(r"src\lib\openjp2\*.h", rf"{{inc_dir}}\openjpeg-{V['OPENJPEG']}"), ], "libs": [r"bin\*.lib"], }, @@ -348,9 +365,9 @@ DEPS = { "libs": [r"imagequant.lib"], }, "harfbuzz": { - "url": "https://github.com/harfbuzz/harfbuzz/archive/8.3.0.zip", - "filename": "harfbuzz-8.3.0.zip", - "dir": "harfbuzz-8.3.0", + "url": f"https://github.com/harfbuzz/harfbuzz/archive/{V['HARFBUZZ']}.zip", + "filename": f"harfbuzz-{V['HARFBUZZ']}.zip", + "dir": f"harfbuzz-{V['HARFBUZZ']}", "license": "COPYING", "build": [ *cmds_cmake( @@ -363,12 +380,12 @@ DEPS = { "libs": [r"*.lib"], }, "fribidi": { - "url": "https://github.com/fribidi/fribidi/archive/v1.0.13.zip", - "filename": "fribidi-1.0.13.zip", - "dir": "fribidi-1.0.13", + "url": f"https://github.com/fribidi/fribidi/archive/v{V['FRIBIDI']}.zip", + "filename": f"fribidi-{V['FRIBIDI']}.zip", + "dir": f"fribidi-{V['FRIBIDI']}", "license": "COPYING", "build": [ - cmd_copy(r"COPYING", r"{bin_dir}\fribidi-1.0.13-COPYING"), + cmd_copy(r"COPYING", rf"{{bin_dir}}\fribidi-{V['FRIBIDI']}-COPYING"), cmd_copy(r"{winbuild_dir}\fribidi.cmake", r"CMakeLists.txt"), # generated tab.i files cannot be cross-compiled " ^&^& ".join( @@ -445,6 +462,7 @@ def find_msvs(architecture: str) -> dict[str, str] | None: def download_dep(url: str, file: str) -> None: + import urllib.error import urllib.request ex = None @@ -461,11 +479,14 @@ def download_dep(url: str, file: str) -> None: raise RuntimeError(ex) -def extract_dep(url: str, filename: str) -> None: +def extract_dep(url: str, filename: str, prefs: dict[str, str]) -> None: import tarfile import zipfile - file = os.path.join(args.depends_dir, filename) + depends_dir = prefs["depends_dir"] + sources_dir = prefs["src_dir"] + + file = os.path.join(depends_dir, filename) if not os.path.exists(file): # First try our mirror mirror_url = ( @@ -504,13 +525,15 @@ def extract_dep(url: str, filename: str) -> None: raise RuntimeError(msg) -def write_script(name: str, lines: list[str]) -> None: - name = os.path.join(args.build_dir, name) +def write_script( + name: str, lines: list[str], prefs: dict[str, str], verbose: bool +) -> None: + name = os.path.join(prefs["build_dir"], name) lines = [line.format(**prefs) for line in lines] print("Writing " + name) with open(name, "w", newline="") as f: f.write(os.linesep.join(lines)) - if args.verbose: + if verbose: for line in lines: print(" " + line) @@ -526,7 +549,7 @@ def get_footer(dep: dict) -> list[str]: return lines -def build_env() -> None: +def build_env(prefs: dict[str, str], verbose: bool) -> None: lines = [ "if defined DISTUTILS_USE_SDK goto end", cmd_set("INCLUDE", "{inc_dir}"), @@ -539,33 +562,35 @@ def build_env() -> None: ":end", "@echo on", ] - write_script("build_env.cmd", lines) + write_script("build_env.cmd", lines, prefs, verbose) -def build_dep(name: str) -> str: +def build_dep(name: str, prefs: dict[str, str], verbose: bool) -> str: dep = DEPS[name] - dir = dep["dir"] + directory = dep["dir"] file = f"build_dep_{name}.cmd" + license_dir = prefs["license_dir"] + sources_dir = prefs["src_dir"] - extract_dep(dep["url"], dep["filename"]) + extract_dep(dep["url"], dep["filename"], prefs) licenses = dep["license"] if isinstance(licenses, str): licenses = [licenses] license_text = "" for license_file in licenses: - with open(os.path.join(sources_dir, dir, license_file)) as f: + with open(os.path.join(sources_dir, directory, license_file)) as f: license_text += f.read() if "license_pattern" in dep: match = re.search(dep["license_pattern"], license_text, re.DOTALL) license_text = "\n".join(match.groups()) assert len(license_text) > 50 - with open(os.path.join(license_dir, f"{dir}.txt"), "w") as f: - print(f"Writing license {dir}.txt") + with open(os.path.join(license_dir, f"{directory}.txt"), "w") as f: + print(f"Writing license {directory}.txt") f.write(license_text) for patch_file, patch_list in dep.get("patch", {}).items(): - patch_file = os.path.join(sources_dir, dir, patch_file.format(**prefs)) + patch_file = os.path.join(sources_dir, directory, patch_file.format(**prefs)) with open(patch_file) as f: text = f.read() for patch_from, patch_to in patch_list.items(): @@ -577,22 +602,22 @@ def build_dep(name: str) -> str: print(f"Patching {patch_file}") f.write(text) - banner = f"Building {name} ({dir})" + banner = f"Building {name} ({directory})" lines = [ r'call "{build_dir}\build_env.cmd"', "@echo " + ("=" * 70), f"@echo ==== {banner:<60} ====", "@echo " + ("=" * 70), - cmd_cd(os.path.join(sources_dir, dir)), + cmd_cd(os.path.join(sources_dir, directory)), *dep.get("build", []), *get_footer(dep), ] - write_script(file, lines) + write_script(file, lines, prefs, verbose) return file -def build_dep_all() -> None: +def build_dep_all(disabled: list[str], prefs: dict[str, str], verbose: bool) -> None: lines = [r'call "{build_dir}\build_env.cmd"'] gha_groups = "GITHUB_ACTIONS" in os.environ for dep_name in DEPS: @@ -600,7 +625,7 @@ def build_dep_all() -> None: if dep_name in disabled: print(f"Skipping disabled dependency {dep_name}") continue - script = build_dep(dep_name) + script = build_dep(dep_name, prefs, verbose) if gha_groups: lines.append(f"@echo ::group::Running {script}") lines.append(rf'cmd.exe /c "{{build_dir}}\{script}"') @@ -609,12 +634,11 @@ def build_dep_all() -> None: lines.append("@echo ::endgroup::") print() lines.append("@echo All Pillow dependencies built successfully!") - write_script("build_dep_all.cmd", lines) + write_script("build_dep_all.cmd", lines, prefs, verbose) -if __name__ == "__main__": +def main() -> None: winbuild_dir = os.path.dirname(os.path.realpath(__file__)) - pillow_dir = os.path.realpath(os.path.join(winbuild_dir, "..")) parser = argparse.ArgumentParser( prog="winbuild\\build_prepare.py", @@ -651,7 +675,7 @@ if __name__ == "__main__": ( "ARM64" if platform.machine() == "ARM64" - else ("x86" if struct.calcsize("P") == 4 else "x64") + else ("x86" if struct.calcsize("P") == 4 else "AMD64") ), ), help="build architecture (default: same as host Python)", @@ -720,15 +744,15 @@ if __name__ == "__main__": "architecture": args.architecture, **arch_prefs, # Pillow paths - "pillow_dir": pillow_dir, "winbuild_dir": winbuild_dir, # Build paths + "bin_dir": bin_dir, "build_dir": args.build_dir, + "depends_dir": args.depends_dir, "inc_dir": inc_dir, "lib_dir": lib_dir, - "bin_dir": bin_dir, - "src_dir": sources_dir, "license_dir": license_dir, + "src_dir": sources_dir, # Compilers / Tools **msvs, "cmake": "cmake.exe", # TODO find CMAKE automatically @@ -741,6 +765,10 @@ if __name__ == "__main__": print() - write_script(".gitignore", ["*"]) - build_env() - build_dep_all() + write_script(".gitignore", ["*"], prefs, args.verbose) + build_env(prefs, args.verbose) + build_dep_all(disabled, prefs, args.verbose) + + +if __name__ == "__main__": + main()