diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 77d1d1caa..4f67be6f7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,55 +2,31 @@ name: Lint on: [push, pull_request, workflow_dispatch] +permissions: {} + env: FORCE_COLOR: 1 - -permissions: - contents: read + PREK_COLOR: always + RUFF_OUTPUT_FORMAT: github concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - build: - + lint: runs-on: ubuntu-latest - name: Lint - steps: - - uses: actions/checkout@v6 - with: - persist-credentials: false - - - name: pre-commit cache - uses: actions/cache@v4 - with: - path: ~/.cache/pre-commit - key: lint-pre-commit-${{ hashFiles('**/.pre-commit-config.yaml') }} - restore-keys: | - lint-pre-commit- - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.x" - cache: pip - cache-dependency-path: "setup.py" - - - name: Build system information - run: python3 .github/workflows/system-info.py - - - name: Install dependencies - run: | - python3 -m pip install -U pip - python3 -m pip install -U tox - - - name: Lint - run: tox -e lint - env: - PRE_COMMIT_COLOR: always - - - name: Mypy - run: tox -e mypy + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: "3.10" + - name: Install uv + uses: astral-sh/setup-uv@v7 + - name: Lint + run: uvx --with tox-uv tox -e lint + - name: Mypy + run: uvx --with tox-uv tox -e mypy diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 3450de355..e864763da 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -31,15 +31,16 @@ env: jobs: build: - runs-on: windows-latest + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: python-version: ["pypy3.11", "3.11", "3.12", "3.13", "3.14", "3.15"] architecture: ["x64"] + os: ["windows-latest"] include: # Test the oldest Python on 32-bit - - { python-version: "3.10", architecture: "x86" } + - { python-version: "3.10", architecture: "x86", os: "windows-2022" } timeout-minutes: 45 @@ -83,7 +84,7 @@ jobs: python3 -m pip install --upgrade pip - name: Install CPython dependencies - if: "!contains(matrix.python-version, 'pypy') && !contains(matrix.python-version, '3.14') && matrix.architecture != 'x86'" + if: "!contains(matrix.python-version, 'pypy') && matrix.architecture != 'x86'" run: | python3 -m pip install PyQt6 diff --git a/.github/workflows/wheels-dependencies.sh b/.github/workflows/wheels-dependencies.sh index b246d9255..e1586b7c5 100755 --- a/.github/workflows/wheels-dependencies.sh +++ b/.github/workflows/wheels-dependencies.sh @@ -95,11 +95,11 @@ if [[ -n "$IOS_SDK" ]]; then else FREETYPE_VERSION=2.14.1 fi -HARFBUZZ_VERSION=12.2.0 +HARFBUZZ_VERSION=12.3.0 LIBPNG_VERSION=1.6.53 -JPEGTURBO_VERSION=3.1.2 +JPEGTURBO_VERSION=3.1.3 OPENJPEG_VERSION=2.5.4 -XZ_VERSION=5.8.1 +XZ_VERSION=5.8.2 ZSTD_VERSION=1.5.7 TIFF_VERSION=4.7.1 LCMS2_VERSION=2.17 diff --git a/.github/zizmor.yml b/.github/zizmor.yml index e60c79441..f4949c30c 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,4 +1,3 @@ -# Configuration for the zizmor static analysis tool, run via pre-commit in CI # https://docs.zizmor.sh/configuration/ rules: obfuscation: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8477729e6..10343f91a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,7 +76,7 @@ repos: rev: v0.24.1 hooks: - id: validate-pyproject - additional_dependencies: [trove-classifiers>=2024.10.12] + additional_dependencies: [tomli, trove-classifiers>=2024.10.12] - repo: https://github.com/tox-dev/tox-ini-fmt rev: 1.7.0 diff --git a/Tests/fonts/AdobeVFPrototypeDuplicates.ttf b/Tests/fonts/AdobeVFPrototypeDuplicates.ttf new file mode 100644 index 000000000..acf0bc156 Binary files /dev/null and b/Tests/fonts/AdobeVFPrototypeDuplicates.ttf differ diff --git a/Tests/fonts/LICENSE.txt b/Tests/fonts/LICENSE.txt index 3c8a23197..94989af90 100644 --- a/Tests/fonts/LICENSE.txt +++ b/Tests/fonts/LICENSE.txt @@ -2,7 +2,7 @@ NotoNastaliqUrdu-Regular.ttf and NotoSansSymbols-Regular.ttf, from https://github.com/googlei18n/noto-fonts NotoSans-Regular.ttf, from https://www.google.com/get/noto/ NotoSansJP-Thin.otf, from https://www.google.com/get/noto/help/cjk/ -AdobeVFPrototype.ttf, from https://github.com/adobe-fonts/adobe-variable-font-prototype +AdobeVFPrototype.ttf, from https://github.com/adobe-fonts/adobe-variable-font-prototype. AdobeVFPrototypeDuplicates.ttf is a modified version of this TINY5x3GX.ttf, from http://velvetyne.fr/fonts/tiny ArefRuqaa-Regular.ttf, from https://github.com/google/fonts/tree/master/ofl/arefruqaa ter-x20b.pcf, from http://terminus-font.sourceforge.net/ diff --git a/Tests/helper.py b/Tests/helper.py index dbdd30b42..d77b4b807 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -55,8 +55,8 @@ def convert_to_comparable( if a.mode == "P": new_a = Image.new("L", a.size) new_b = Image.new("L", b.size) - new_a.putdata(a.getdata()) - new_b.putdata(b.getdata()) + new_a.putdata(a.get_flattened_data()) + new_b.putdata(b.get_flattened_data()) elif a.mode == "I;16": new_a = a.convert("I") new_b = b.convert("I") @@ -104,10 +104,9 @@ def assert_image_equal_tofile( 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) + with Image.open(filename) as im: + converted_im = im.convert(mode) if mode else im + assert_image_equal(a, converted_im, msg) def assert_image_similar( diff --git a/Tests/images/morph_a.png b/Tests/images/morph_a.png index 19f6b777f..035fbc4bb 100644 Binary files a/Tests/images/morph_a.png and b/Tests/images/morph_a.png differ diff --git a/Tests/test_bmp_reference.py b/Tests/test_bmp_reference.py index 3cd0fbb2d..8fbd73748 100644 --- a/Tests/test_bmp_reference.py +++ b/Tests/test_bmp_reference.py @@ -95,16 +95,16 @@ def test_good() -> None: for f in get_files("g"): try: with Image.open(f) as im: - im.load() with Image.open(get_compare(f)) as compare: - compare.load() - if im.mode == "P": - # assert image similar doesn't really work - # with paletized image, since the palette might - # be differently ordered for an equivalent image. - im = im.convert("RGBA") - compare = compare.convert("RGBA") - assert_image_similar(im, compare, 5) + # assert image similar doesn't really work + # with paletized image, since the palette might + # be differently ordered for an equivalent image. + im_converted = im.convert("RGBA") if im.mode == "P" else im + compare_converted = ( + compare.convert("RGBA") if im.mode == "P" else compare + ) + + assert_image_similar(im_converted, compare_converted, 5) except Exception as msg: # there are three here that are unsupported: diff --git a/Tests/test_box_blur.py b/Tests/test_box_blur.py index cb267b204..07e62db8c 100644 --- a/Tests/test_box_blur.py +++ b/Tests/test_box_blur.py @@ -28,9 +28,13 @@ def box_blur(image: Image.Image, radius: float = 1, n: int = 1) -> Image.Image: def assert_image(im: Image.Image, data: list[list[int]], delta: int = 0) -> None: - it = iter(im.getdata()) + it = iter(im.get_flattened_data()) for data_row in data: - im_row = [next(it) for _ in range(im.size[0])] + im_row = [] + for _ in range(im.width): + im_v = next(it) + assert isinstance(im_v, (int, float)) + im_row.append(im_v) if any(abs(data_v - im_v) > delta for data_v, im_v in zip(data_row, im_row)): assert im_row == data_row with pytest.raises(StopIteration): diff --git a/Tests/test_file_apng.py b/Tests/test_file_apng.py index d918a24a7..b57a1d1ad 100644 --- a/Tests/test_file_apng.py +++ b/Tests/test_file_apng.py @@ -278,25 +278,25 @@ def test_apng_mode() -> None: assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) - im = im.convert("RGB") - assert im.getpixel((0, 0)) == (0, 255, 0) - assert im.getpixel((64, 32)) == (0, 255, 0) + im_rgb = im.convert("RGB") + assert im_rgb.getpixel((0, 0)) == (0, 255, 0) + assert im_rgb.getpixel((64, 32)) == (0, 255, 0) with Image.open("Tests/images/apng/mode_palette_alpha.png") as im: assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) - im = im.convert("RGBA") - assert im.getpixel((0, 0)) == (0, 255, 0, 255) - assert im.getpixel((64, 32)) == (0, 255, 0, 255) + im_rgba = im.convert("RGBA") + assert im_rgba.getpixel((0, 0)) == (0, 255, 0, 255) + assert im_rgba.getpixel((64, 32)) == (0, 255, 0, 255) with Image.open("Tests/images/apng/mode_palette_1bit_alpha.png") as im: assert isinstance(im, PngImagePlugin.PngImageFile) assert im.mode == "P" im.seek(im.n_frames - 1) - im = im.convert("RGBA") - assert im.getpixel((0, 0)) == (0, 0, 255, 128) - assert im.getpixel((64, 32)) == (0, 0, 255, 128) + im_rgba = im.convert("RGBA") + assert im_rgba.getpixel((0, 0)) == (0, 0, 255, 128) + assert im_rgba.getpixel((64, 32)) == (0, 0, 255, 128) def test_apng_chunk_errors() -> None: @@ -518,6 +518,24 @@ def test_apng_save_duration_loop(tmp_path: Path) -> None: assert im.info["duration"] == 600 +def test_apng_save_duration_float(tmp_path: Path) -> None: + test_file = tmp_path / "temp.png" + im = Image.new("1", (1, 1)) + im2 = Image.new("1", (1, 1), 1) + im.save(test_file, save_all=True, append_images=[im2], duration=0.5) + + with Image.open(test_file) as reloaded: + assert reloaded.info["duration"] == 0.5 + + +def test_apng_save_large_duration(tmp_path: Path) -> None: + test_file = tmp_path / "temp.png" + im = Image.new("1", (1, 1)) + im2 = Image.new("1", (1, 1), 1) + with pytest.raises(ValueError, match="cannot write duration"): + im.save(test_file, save_all=True, append_images=[im2], duration=65536000) + + def test_apng_save_disposal(tmp_path: Path) -> None: test_file = tmp_path / "temp.png" size = (128, 64) diff --git a/Tests/test_file_avif.py b/Tests/test_file_avif.py index 727191153..ffc4ce021 100644 --- a/Tests/test_file_avif.py +++ b/Tests/test_file_avif.py @@ -121,7 +121,6 @@ class TestFileAvif: assert image.size == (128, 128) assert image.format == "AVIF" assert image.get_format_mimetype() == "image/avif" - image.getdata() # generated with: # avifdec hopper.avif hopper_avif_write.png @@ -143,7 +142,6 @@ class TestFileAvif: assert reloaded.mode == "RGB" assert reloaded.size == (128, 128) assert reloaded.format == "AVIF" - reloaded.getdata() # avifdec hopper.avif avif/hopper_avif_write.png assert_image_similar_tofile( diff --git a/Tests/test_file_bmp.py b/Tests/test_file_bmp.py index c1c430aa5..28e863459 100644 --- a/Tests/test_file_bmp.py +++ b/Tests/test_file_bmp.py @@ -165,9 +165,9 @@ def test_rgba_bitfields() -> None: with Image.open("Tests/images/rgb32bf-rgba.bmp") as im: # So before the comparing the image, swap the channels b, g, r = im.split()[1:] - im = Image.merge("RGB", (r, g, b)) + im_rgb = Image.merge("RGB", (r, g, b)) - assert_image_equal_tofile(im, "Tests/images/bmp/q/rgb32bf-xbgr.bmp") + assert_image_equal_tofile(im_rgb, "Tests/images/bmp/q/rgb32bf-xbgr.bmp") # This test image has been manually hexedited # to change the bitfield compression in the header from XBGR to ABGR diff --git a/Tests/test_file_bufrstub.py b/Tests/test_file_bufrstub.py index 362578c56..8c6bb1a69 100644 --- a/Tests/test_file_bufrstub.py +++ b/Tests/test_file_bufrstub.py @@ -61,6 +61,7 @@ def test_handler(tmp_path: Path) -> None: def load(self, im: ImageFile.StubImageFile) -> Image.Image: self.loaded = True + assert im.fp is not None im.fp.close() return Image.new("RGB", (1, 1)) diff --git a/Tests/test_file_dds.py b/Tests/test_file_dds.py index 60d0c09bc..931ff02f1 100644 --- a/Tests/test_file_dds.py +++ b/Tests/test_file_dds.py @@ -57,7 +57,7 @@ TEST_FILE_UNCOMPRESSED_RGB_WITH_ALPHA = "Tests/images/uncompressed_rgb.dds" 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") + target_rgba = target.convert("RGBA") with Image.open(image_path) as im: im.load() @@ -65,7 +65,7 @@ def test_sanity_dxt1_bc1(image_path: str) -> None: assert im.mode == "RGBA" assert im.size == (256, 256) - assert_image_equal(im, target) + assert_image_equal(im, target_rgba) def test_sanity_dxt3() -> None: @@ -520,9 +520,9 @@ def test_save_dx10_bc5(tmp_path: Path) -> None: im.save(out, pixel_format="BC5") assert_image_similar_tofile(im, out, 9.56) - im = hopper("L") + im_l = hopper("L") with pytest.raises(OSError, match="only RGB mode can be written as BC5"): - im.save(out, pixel_format="BC5") + im_l.save(out, pixel_format="BC5") @pytest.mark.parametrize( diff --git a/Tests/test_file_eps.py b/Tests/test_file_eps.py index b50915f28..d4e8db4f4 100644 --- a/Tests/test_file_eps.py +++ b/Tests/test_file_eps.py @@ -265,9 +265,9 @@ def test_bytesio_object() -> None: img.load() with Image.open(FILE1_COMPARE) as image1_scale1_compare: - image1_scale1_compare = image1_scale1_compare.convert("RGB") - image1_scale1_compare.load() - assert_image_similar(img, image1_scale1_compare, 5) + image1_scale1_compare_rgb = image1_scale1_compare.convert("RGB") + image1_scale1_compare_rgb.load() + assert_image_similar(img, image1_scale1_compare_rgb, 5) @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @@ -301,17 +301,17 @@ def test_render_scale1() -> None: with Image.open(FILE1) as image1_scale1: image1_scale1.load() with Image.open(FILE1_COMPARE) as image1_scale1_compare: - image1_scale1_compare = image1_scale1_compare.convert("RGB") - image1_scale1_compare.load() - assert_image_similar(image1_scale1, image1_scale1_compare, 5) + image1_scale1_compare_rgb = image1_scale1_compare.convert("RGB") + image1_scale1_compare_rgb.load() + assert_image_similar(image1_scale1, image1_scale1_compare_rgb, 5) # Non-zero bounding box with Image.open(FILE2) as image2_scale1: image2_scale1.load() with Image.open(FILE2_COMPARE) as image2_scale1_compare: - image2_scale1_compare = image2_scale1_compare.convert("RGB") - image2_scale1_compare.load() - assert_image_similar(image2_scale1, image2_scale1_compare, 10) + image2_scale1_compare_rgb = image2_scale1_compare.convert("RGB") + image2_scale1_compare_rgb.load() + assert_image_similar(image2_scale1, image2_scale1_compare_rgb, 10) @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @@ -324,18 +324,16 @@ def test_render_scale2() -> None: assert isinstance(image1_scale2, EpsImagePlugin.EpsImageFile) image1_scale2.load(scale=2) with Image.open(FILE1_COMPARE_SCALE2) as image1_scale2_compare: - image1_scale2_compare = image1_scale2_compare.convert("RGB") - image1_scale2_compare.load() - assert_image_similar(image1_scale2, image1_scale2_compare, 5) + image1_scale2_compare_rgb = image1_scale2_compare.convert("RGB") + assert_image_similar(image1_scale2, image1_scale2_compare_rgb, 5) # Non-zero bounding box with Image.open(FILE2) as image2_scale2: assert isinstance(image2_scale2, EpsImagePlugin.EpsImageFile) image2_scale2.load(scale=2) with Image.open(FILE2_COMPARE_SCALE2) as image2_scale2_compare: - image2_scale2_compare = image2_scale2_compare.convert("RGB") - image2_scale2_compare.load() - assert_image_similar(image2_scale2, image2_scale2_compare, 10) + image2_scale2_compare_rgb = image2_scale2_compare.convert("RGB") + assert_image_similar(image2_scale2, image2_scale2_compare_rgb, 10) @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") @@ -345,8 +343,8 @@ def test_render_scale2() -> None: def test_resize(filename: str) -> None: with Image.open(filename) as im: new_size = (100, 100) - im = im.resize(new_size) - assert im.size == new_size + im_resized = im.resize(new_size) + assert im_resized.size == new_size @pytest.mark.skipif(not HAS_GHOSTSCRIPT, reason="Ghostscript not available") diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index acf79374e..2615f5a60 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -327,14 +327,13 @@ def test_loading_multiple_palettes(path: str, mode: str) -> None: im.seek(1) assert im.mode == mode - if mode == "RGBA": - im = im.convert("RGB") + im_rgb = im.convert("RGB") if mode == "RGBA" else im # Check a color only from the old palette - assert im.getpixel((0, 0)) == original_color + assert im_rgb.getpixel((0, 0)) == original_color # Check a color from the new palette - assert im.getpixel((24, 24)) not in first_frame_colors + assert im_rgb.getpixel((24, 24)) not in first_frame_colors def test_headers_saving_for_animated_gifs(tmp_path: Path) -> None: @@ -354,16 +353,16 @@ def test_palette_handling(tmp_path: Path) -> None: # see https://github.com/python-pillow/Pillow/issues/513 with Image.open(TEST_GIF) as im: - im = im.convert("RGB") + im_rgb = im.convert("RGB") - im = im.resize((100, 100), Image.Resampling.LANCZOS) - im2 = im.convert("P", palette=Image.Palette.ADAPTIVE, colors=256) + im_rgb = im_rgb.resize((100, 100), Image.Resampling.LANCZOS) + im_p = im_rgb.convert("P", palette=Image.Palette.ADAPTIVE, colors=256) - f = tmp_path / "temp.gif" - im2.save(f, optimize=True) + f = tmp_path / "temp.gif" + im_p.save(f, optimize=True) with Image.open(f) as reloaded: - assert_image_similar(im, reloaded.convert("RGB"), 10) + assert_image_similar(im_rgb, reloaded.convert("RGB"), 10) def test_palette_434(tmp_path: Path) -> None: @@ -383,35 +382,36 @@ def test_palette_434(tmp_path: Path) -> None: with roundtrip(im, optimize=True) as reloaded: assert_image_similar(im, reloaded, 1) - im = im.convert("RGB") - # check automatic P conversion - with roundtrip(im) as reloaded: - reloaded = reloaded.convert("RGB") - assert_image_equal(im, reloaded) + im_rgb = im.convert("RGB") + + # check automatic P conversion + with roundtrip(im_rgb) as reloaded: + reloaded = reloaded.convert("RGB") + assert_image_equal(im_rgb, reloaded) @pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") def test_save_netpbm_bmp_mode(tmp_path: Path) -> None: with Image.open(TEST_GIF) as img: - img = img.convert("RGB") + img_rgb = img.convert("RGB") - tempfile = str(tmp_path / "temp.gif") - b = BytesIO() - GifImagePlugin._save_netpbm(img, b, tempfile) - with Image.open(tempfile) as reloaded: - assert_image_similar(img, reloaded.convert("RGB"), 0) + tempfile = str(tmp_path / "temp.gif") + b = BytesIO() + GifImagePlugin._save_netpbm(img_rgb, b, tempfile) + with Image.open(tempfile) as reloaded: + assert_image_similar(img_rgb, reloaded.convert("RGB"), 0) @pytest.mark.skipif(not netpbm_available(), reason="Netpbm not available") def test_save_netpbm_l_mode(tmp_path: Path) -> None: with Image.open(TEST_GIF) as img: - img = img.convert("L") + img_l = img.convert("L") tempfile = str(tmp_path / "temp.gif") b = BytesIO() - GifImagePlugin._save_netpbm(img, b, tempfile) + GifImagePlugin._save_netpbm(img_l, b, tempfile) with Image.open(tempfile) as reloaded: - assert_image_similar(img, reloaded.convert("L"), 0) + assert_image_similar(img_l, reloaded.convert("L"), 0) def test_seek() -> None: @@ -1038,9 +1038,9 @@ def test_webp_background(tmp_path: Path) -> None: im.save(out) # Test non-opaque WebP background - im = Image.new("L", (100, 100), "#000") - im.info["background"] = (0, 0, 0, 0) - im.save(out) + im2 = Image.new("L", (100, 100), "#000") + im2.info["background"] = (0, 0, 0, 0) + im2.save(out) def test_comment(tmp_path: Path) -> None: @@ -1048,16 +1048,16 @@ def test_comment(tmp_path: Path) -> None: assert im.info["comment"] == b"File written by Adobe Photoshop\xa8 4.0" out = tmp_path / "temp.gif" - im = Image.new("L", (100, 100), "#000") - im.info["comment"] = b"Test comment text" - im.save(out) + im2 = Image.new("L", (100, 100), "#000") + im2.info["comment"] = b"Test comment text" + im2.save(out) with Image.open(out) as reread: - assert reread.info["comment"] == im.info["comment"] + assert reread.info["comment"] == im2.info["comment"] - im.info["comment"] = "Test comment text" - im.save(out) + im2.info["comment"] = "Test comment text" + im2.save(out) with Image.open(out) as reread: - assert reread.info["comment"] == im.info["comment"].encode() + assert reread.info["comment"] == im2.info["comment"].encode() # Test that GIF89a is used for comments assert reread.info["version"] == b"GIF89a" diff --git a/Tests/test_file_gribstub.py b/Tests/test_file_gribstub.py index 4dbed6b31..05925d502 100644 --- a/Tests/test_file_gribstub.py +++ b/Tests/test_file_gribstub.py @@ -61,6 +61,7 @@ def test_handler(tmp_path: Path) -> None: def load(self, im: ImageFile.ImageFile) -> Image.Image: self.loaded = True + assert im.fp is not None im.fp.close() return Image.new("RGB", (1, 1)) diff --git a/Tests/test_file_hdf5stub.py b/Tests/test_file_hdf5stub.py index 1e48597d3..e1a56309b 100644 --- a/Tests/test_file_hdf5stub.py +++ b/Tests/test_file_hdf5stub.py @@ -63,6 +63,7 @@ def test_handler(tmp_path: Path) -> None: def load(self, im: ImageFile.ImageFile) -> Image.Image: self.loaded = True + assert im.fp is not None im.fp.close() return Image.new("RGB", (1, 1)) diff --git a/Tests/test_file_iptc.py b/Tests/test_file_iptc.py index 9e2d8c06d..3eb5cde8e 100644 --- a/Tests/test_file_iptc.py +++ b/Tests/test_file_iptc.py @@ -6,7 +6,7 @@ import pytest from PIL import Image, IptcImagePlugin, TiffImagePlugin, TiffTags -from .helper import assert_image_equal, hopper +from .helper import assert_image_equal TEST_FILE = "Tests/images/iptc.jpg" @@ -85,7 +85,7 @@ def test_getiptcinfo() -> None: def test_getiptcinfo_jpg_none() -> None: # Arrange - with hopper() as im: + with Image.open("Tests/images/hopper.jpg") as im: # Act iptc = IptcImagePlugin.getiptcinfo(im) diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 96e7f4239..f818927f6 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -1133,8 +1133,9 @@ class TestFileCloseW32: im.save(tmpfile) im = Image.open(tmpfile) + assert im.fp is not None + assert not im.fp.closed fp = im.fp - assert not fp.closed with pytest.raises(OSError): os.remove(tmpfile) im.load() diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index a5365a90d..575d911de 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -164,7 +164,7 @@ def test_reduce() -> None: with Image.open("Tests/images/test-card-lossless.jp2") as im: assert callable(im.reduce) - im.reduce = 2 + im.reduce = 2 # type: ignore[assignment, method-assign] assert im.reduce == 2 im.load() diff --git a/Tests/test_file_libtiff.py b/Tests/test_file_libtiff.py index e36b5f39e..c2336c058 100644 --- a/Tests/test_file_libtiff.py +++ b/Tests/test_file_libtiff.py @@ -42,7 +42,6 @@ class LibTiffTestCase: # Does the data actually load im.load() - im.getdata() assert isinstance(im, TiffImagePlugin.TiffImageFile) assert im._compression == "group4" @@ -516,12 +515,12 @@ class TestFileLibTiff(LibTiffTestCase): # and save to compressed tif. out = tmp_path / "temp.tif" with Image.open("Tests/images/pport_g4.tif") as im: - im = im.convert("L") + im_l = im.convert("L") - im = im.filter(ImageFilter.GaussianBlur(4)) - im.save(out, compression="tiff_adobe_deflate") + im_l = im_l.filter(ImageFilter.GaussianBlur(4)) + im_l.save(out, compression="tiff_adobe_deflate") - assert_image_equal_tofile(im, out) + assert_image_equal_tofile(im_l, out) def test_compressions(self, tmp_path: Path) -> None: # Test various tiff compressions and assert similar image content but reduced @@ -610,8 +609,9 @@ class TestFileLibTiff(LibTiffTestCase): im.save(out, compression=compression) def test_fp_leak(self) -> None: - im: Image.Image | None = Image.open("Tests/images/hopper_g4_500.tif") + im: ImageFile.ImageFile | None = Image.open("Tests/images/hopper_g4_500.tif") assert im is not None + assert im.fp is not None fn = im.fp.fileno() os.fstat(fn) @@ -1087,8 +1087,10 @@ class TestFileLibTiff(LibTiffTestCase): data = data[:102] + b"\x02" + data[103:] with Image.open(io.BytesIO(data)) as im: - im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) - assert_image_equal_tofile(im, "Tests/images/old-style-jpeg-compression.png") + im_transposed = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + assert_image_equal_tofile( + im_transposed, "Tests/images/old-style-jpeg-compression.png" + ) def test_open_missing_samplesperpixel(self) -> None: with Image.open( diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 7f163a4d6..ed3a91285 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -101,12 +101,13 @@ class TestFilePng: assert im.get_format_mimetype() == "image/png" for mode in ["1", "L", "P", "RGB", "I;16", "I;16B"]: - im = hopper(mode) - im.save(test_file) + im1 = hopper(mode) + im1.save(test_file) with Image.open(test_file) as reloaded: - if mode == "I;16B": - reloaded = reloaded.convert(mode) - assert_image_equal(reloaded, im) + converted_reloaded = ( + reloaded.convert(mode) if mode == "I;16B" else reloaded + ) + assert_image_equal(converted_reloaded, im1) def test_invalid_file(self) -> None: invalid_file = "Tests/images/flower.jpg" @@ -225,11 +226,11 @@ class TestFilePng: test_file = "Tests/images/pil123p.png" with Image.open(test_file) as im: assert_image(im, "P", (162, 150)) - im = im.convert("RGBA") - assert_image(im, "RGBA", (162, 150)) + im_rgba = im.convert("RGBA") + assert_image(im_rgba, "RGBA", (162, 150)) # image has 124 unique alpha values - colors = im.getchannel("A").getcolors() + colors = im_rgba.getchannel("A").getcolors() assert colors is not None assert len(colors) == 124 @@ -239,11 +240,11 @@ class TestFilePng: assert im.info["transparency"] == (0, 255, 52) assert_image(im, "RGB", (64, 64)) - im = im.convert("RGBA") - assert_image(im, "RGBA", (64, 64)) + im_rgba = im.convert("RGBA") + assert_image(im_rgba, "RGBA", (64, 64)) # image has 876 transparent pixels - colors = im.getchannel("A").getcolors() + colors = im_rgba.getchannel("A").getcolors() assert colors is not None assert colors[0][0] == 876 @@ -262,11 +263,11 @@ class TestFilePng: assert len(im.info["transparency"]) == 256 assert_image(im, "P", (162, 150)) - im = im.convert("RGBA") - assert_image(im, "RGBA", (162, 150)) + im_rgba = im.convert("RGBA") + assert_image(im_rgba, "RGBA", (162, 150)) # image has 124 unique alpha values - colors = im.getchannel("A").getcolors() + colors = im_rgba.getchannel("A").getcolors() assert colors is not None assert len(colors) == 124 @@ -285,13 +286,13 @@ class TestFilePng: assert im.info["transparency"] == 164 assert im.getpixel((31, 31)) == 164 assert_image(im, "P", (64, 64)) - im = im.convert("RGBA") - assert_image(im, "RGBA", (64, 64)) + im_rgba = im.convert("RGBA") + assert_image(im_rgba, "RGBA", (64, 64)) - assert im.getpixel((31, 31)) == (0, 255, 52, 0) + assert im_rgba.getpixel((31, 31)) == (0, 255, 52, 0) # image has 876 transparent pixels - colors = im.getchannel("A").getcolors() + colors = im_rgba.getchannel("A").getcolors() assert colors is not None assert colors[0][0] == 876 @@ -822,7 +823,7 @@ class TestFilePng: monkeypatch.setattr(sys, "stdout", mystdout) with Image.open(TEST_PNG_FILE) as im: - im.save(sys.stdout, "PNG") + im.save(sys.stdout, "PNG") # type: ignore[arg-type] if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer diff --git a/Tests/test_file_ppm.py b/Tests/test_file_ppm.py index 598e9a445..fbca46be5 100644 --- a/Tests/test_file_ppm.py +++ b/Tests/test_file_ppm.py @@ -389,7 +389,7 @@ def test_save_stdout(buffer: bool, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(sys, "stdout", mystdout) with Image.open(TEST_FILE) as im: - im.save(sys.stdout, "PPM") + im.save(sys.stdout, "PPM") # type: ignore[arg-type] if isinstance(mystdout, MyStdOut): mystdout = mystdout.buffer diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index 556c88647..c6c8467d6 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -971,6 +971,7 @@ class TestFileTiff: im = Image.open(tmpfile) fp = im.fp + assert fp is not None assert not fp.closed im.load() assert fp.closed @@ -984,6 +985,7 @@ class TestFileTiff: with open(tmpfile, "rb") as f: im = Image.open(f) fp = im.fp + assert fp is not None assert not fp.closed im.load() assert not fp.closed @@ -1034,8 +1036,9 @@ class TestFileTiffW32: im.save(tmpfile) im = Image.open(tmpfile) + assert im.fp is not None + assert not im.fp.closed fp = im.fp - assert not fp.closed with pytest.raises(OSError): os.remove(tmpfile) im.load() diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index 5456adf59..f996cce67 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -60,7 +60,6 @@ class TestFileWebp: assert image.size == (128, 128) assert image.format == "WEBP" image.load() - image.getdata() # generated with: # dwebp -ppm ../../Tests/images/hopper.webp -o hopper_webp_bits.ppm @@ -77,7 +76,6 @@ class TestFileWebp: assert image.size == (128, 128) assert image.format == "WEBP" image.load() - image.getdata() if mode == self.rgb_mode: # generated with: dwebp -ppm temp.webp -o hopper_webp_write.ppm diff --git a/Tests/test_file_webp_alpha.py b/Tests/test_file_webp_alpha.py index c573390c4..b1aa45f6b 100644 --- a/Tests/test_file_webp_alpha.py +++ b/Tests/test_file_webp_alpha.py @@ -29,7 +29,6 @@ def test_read_rgba() -> None: assert image.size == (200, 150) assert image.format == "WEBP" image.load() - image.getdata() image.tobytes() @@ -60,7 +59,6 @@ def test_write_lossless_rgb(tmp_path: Path) -> None: assert image.size == pil_image.size assert image.format == "WEBP" image.load() - image.getdata() assert_image_equal(image, pil_image) @@ -83,7 +81,6 @@ def test_write_rgba(tmp_path: Path) -> None: assert image.size == (10, 10) assert image.format == "WEBP" image.load() - image.getdata() assert_image_similar(image, pil_image, 1.0) @@ -133,7 +130,6 @@ def test_write_unsupported_mode_PA(tmp_path: Path) -> None: assert image.format == "WEBP" image.load() - image.getdata() with Image.open(file_path) as im: target = im.convert("RGBA") diff --git a/Tests/test_file_webp_lossless.py b/Tests/test_file_webp_lossless.py index 5eaa4f599..b4c0448ac 100644 --- a/Tests/test_file_webp_lossless.py +++ b/Tests/test_file_webp_lossless.py @@ -24,6 +24,5 @@ def test_write_lossless_rgb(tmp_path: Path) -> None: assert image.size == (128, 128) assert image.format == "WEBP" image.load() - image.getdata() assert_image_equal(image, hopper(RGB_MODE)) diff --git a/Tests/test_format_lab.py b/Tests/test_format_lab.py index 4fcc37e88..5f4a704f2 100644 --- a/Tests/test_format_lab.py +++ b/Tests/test_format_lab.py @@ -13,15 +13,15 @@ def test_white() -> None: k = i.getpixel((0, 0)) - L = i.getdata(0) - a = i.getdata(1) - b = i.getdata(2) + L = i.get_flattened_data(0) + a = i.get_flattened_data(1) + b = i.get_flattened_data(2) assert k == (255, 128, 128) - assert list(L) == [255] * 100 - assert list(a) == [128] * 100 - assert list(b) == [128] * 100 + assert L == (255,) * 100 + assert a == (128,) * 100 + assert b == (128,) * 100 def test_green() -> None: diff --git a/Tests/test_image.py b/Tests/test_image.py index 88f55638e..afc6e8e16 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -1181,10 +1181,10 @@ class TestImageBytes: assert reloaded.tobytes() == source_bytes @pytest.mark.parametrize("mode", Image.MODES) - def test_getdata_putdata(self, mode: str) -> None: + def test_get_flattened_data_putdata(self, mode: str) -> None: im = hopper(mode) reloaded = Image.new(mode, im.size) - reloaded.putdata(im.getdata()) + reloaded.putdata(im.get_flattened_data()) assert_image_equal(im, reloaded) diff --git a/Tests/test_image_array.py b/Tests/test_image_array.py index abb22f949..220e128d1 100644 --- a/Tests/test_image_array.py +++ b/Tests/test_image_array.py @@ -78,7 +78,7 @@ def test_fromarray() -> None: }, ) out = Image.fromarray(wrapped) - return out.mode, out.size, list(i.getdata()) == list(out.getdata()) + return out.mode, out.size, i.get_flattened_data() == out.get_flattened_data() # assert test("1") == ("1", (128, 100), True) assert test("L") == ("L", (128, 100), True) diff --git a/Tests/test_image_crop.py b/Tests/test_image_crop.py index b90ce84bc..9df8883a4 100644 --- a/Tests/test_image_crop.py +++ b/Tests/test_image_crop.py @@ -95,10 +95,10 @@ def test_crop_zero() -> None: cropped = im.crop((10, 10, 20, 20)) assert cropped.size == (10, 10) - assert cropped.getdata()[0] == (0, 0, 0) + assert cropped.getpixel((0, 0)) == (0, 0, 0) im = Image.new("RGB", (0, 0)) cropped = im.crop((10, 10, 20, 20)) assert cropped.size == (10, 10) - assert cropped.getdata()[2] == (0, 0, 0) + assert cropped.getpixel((2, 0)) == (0, 0, 0) diff --git a/Tests/test_image_getdata.py b/Tests/test_image_getdata.py index c8b213d84..94d6cbaa2 100644 --- a/Tests/test_image_getdata.py +++ b/Tests/test_image_getdata.py @@ -1,23 +1,23 @@ from __future__ import annotations +import pytest + from PIL import Image from .helper import hopper def test_sanity() -> None: - data = hopper().getdata() - - len(data) - list(data) + data = hopper().get_flattened_data() + assert len(data) == 128 * 128 assert data[0] == (20, 20, 70) def test_mode() -> None: def getdata(mode: str) -> tuple[float | tuple[int, ...] | None, int, int]: im = hopper(mode).resize((32, 30), Image.Resampling.NEAREST) - data = im.getdata() + data = im.get_flattened_data() return data[0], len(data), len(list(data)) assert getdata("1") == (0, 960, 960) @@ -28,3 +28,13 @@ def test_mode() -> None: assert getdata("RGBA") == ((11, 13, 52, 255), 960, 960) assert getdata("CMYK") == ((244, 242, 203, 0), 960, 960) assert getdata("YCbCr") == ((16, 147, 123), 960, 960) + + +def test_deprecation() -> None: + im = hopper() + with pytest.warns(DeprecationWarning, match="getdata"): + data = im.getdata() + + assert len(data) == 128 * 128 + assert data[0] == (20, 20, 70) + assert list(data)[0] == (20, 20, 70) diff --git a/Tests/test_image_load.py b/Tests/test_image_load.py index 4f1d63b8f..1d5f0d17c 100644 --- a/Tests/test_image_load.py +++ b/Tests/test_image_load.py @@ -38,6 +38,7 @@ def test_close_after_load(caplog: pytest.LogCaptureFixture) -> None: def test_contextmanager() -> None: fn = None with Image.open("Tests/images/hopper.gif") as im: + assert im.fp is not None fn = im.fp.fileno() os.fstat(fn) diff --git a/Tests/test_image_putdata.py b/Tests/test_image_putdata.py index bf8e89b53..226cb4c14 100644 --- a/Tests/test_image_putdata.py +++ b/Tests/test_image_putdata.py @@ -2,6 +2,7 @@ from __future__ import annotations import sys from array import array +from typing import cast import pytest @@ -12,21 +13,19 @@ from .helper import assert_image_equal, hopper def test_sanity() -> None: im1 = hopper() + for data in (im1.get_flattened_data(), im1.im): + im2 = Image.new(im1.mode, im1.size, 0) + im2.putdata(data) - data = list(im1.getdata()) + assert_image_equal(im1, im2) - im2 = Image.new(im1.mode, im1.size, 0) - im2.putdata(data) + # readonly + im2 = Image.new(im1.mode, im2.size, 0) + im2.readonly = 1 + im2.putdata(data) - assert_image_equal(im1, im2) - - # readonly - im2 = Image.new(im1.mode, im2.size, 0) - im2.readonly = 1 - im2.putdata(data) - - assert not im2.readonly - assert_image_equal(im1, im2) + assert not im2.readonly + assert_image_equal(im1, im2) def test_long_integers() -> None: @@ -60,22 +59,22 @@ def test_mode_with_L_with_float() -> None: @pytest.mark.parametrize("mode", ("I", "I;16", "I;16L", "I;16B")) def test_mode_i(mode: str) -> None: src = hopper("L") - data = list(src.getdata()) + data = src.get_flattened_data() im = Image.new(mode, src.size, 0) im.putdata(data, 2, 256) - target = [2 * elt + 256 for elt in data] - assert list(im.getdata()) == target + target = tuple(2 * elt + 256 for elt in cast(tuple[int, ...], data)) + assert im.get_flattened_data() == target def test_mode_F() -> None: src = hopper("L") - data = list(src.getdata()) + data = src.get_flattened_data() im = Image.new("F", src.size, 0) im.putdata(data, 2.0, 256.0) - target = [2.0 * float(elt) + 256.0 for elt in data] - assert list(im.getdata()) == target + target = tuple(2.0 * float(elt) + 256.0 for elt in cast(tuple[int, ...], data)) + assert im.get_flattened_data() == target def test_array_B() -> None: @@ -86,7 +85,7 @@ def test_array_B() -> None: im = Image.new("L", (150, 100)) im.putdata(arr) - assert len(im.getdata()) == len(arr) + assert len(im.get_flattened_data()) == len(arr) def test_array_F() -> None: @@ -97,7 +96,7 @@ def test_array_F() -> None: arr = array("f", [0.0]) * 15000 im.putdata(arr) - assert len(im.getdata()) == len(arr) + assert len(im.get_flattened_data()) == len(arr) def test_not_flattened() -> None: diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py index 323d31f51..3e8979a5b 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -160,7 +160,7 @@ class TestImagingCoreResize: 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) + assert r.getpixel((0, 0)) == (0, 0, 0) def test_unknown_filter(self) -> None: with pytest.raises(ValueError): diff --git a/Tests/test_image_transform.py b/Tests/test_image_transform.py index 7cf52ddba..3e2b9fee8 100644 --- a/Tests/test_image_transform.py +++ b/Tests/test_image_transform.py @@ -250,14 +250,14 @@ class TestImageTransform: def test_missing_method_data(self) -> None: with hopper() as im: with pytest.raises(ValueError): - im.transform((100, 100), None) + im.transform((100, 100), None) # type: ignore[arg-type] @pytest.mark.parametrize("resample", (Image.Resampling.BOX, "unknown")) def test_unknown_resampling_filter(self, resample: Image.Resampling | str) -> None: with hopper() as im: (w, h) = im.size with pytest.raises(ValueError): - im.transform((100, 100), Image.Transform.EXTENT, (0, 0, w, h), resample) + im.transform((100, 100), Image.Transform.EXTENT, (0, 0, w, h), resample) # type: ignore[arg-type] class TestImageTransformAffine: diff --git a/Tests/test_imagecms.py b/Tests/test_imagecms.py index 5fd7caa7c..a30fb18b8 100644 --- a/Tests/test_imagecms.py +++ b/Tests/test_imagecms.py @@ -274,13 +274,13 @@ def test_simple_lab() -> None: # not a linear luminance map. so L != 128: assert k == (137, 128, 128) - l_data = i_lab.getdata(0) - a_data = i_lab.getdata(1) - b_data = i_lab.getdata(2) + l_data = i_lab.get_flattened_data(0) + a_data = i_lab.get_flattened_data(1) + b_data = i_lab.get_flattened_data(2) - assert list(l_data) == [137] * 100 - assert list(a_data) == [128] * 100 - assert list(b_data) == [128] * 100 + assert l_data == (137,) * 100 + assert a_data == (128,) * 100 + assert b_data == (128,) * 100 def test_lab_color() -> None: diff --git a/Tests/test_imagefont.py b/Tests/test_imagefont.py index ff5ff7e45..ed35f79a0 100644 --- a/Tests/test_imagefont.py +++ b/Tests/test_imagefont.py @@ -702,7 +702,7 @@ def test_variation_get(font: ImageFont.FreeTypeFont) -> None: font.get_variation_axes() font = ImageFont.truetype("Tests/fonts/AdobeVFPrototype.ttf") - assert font.get_variation_names(), [ + assert font.get_variation_names() == [ b"ExtraLight", b"Light", b"Regular", @@ -742,6 +742,21 @@ def test_variation_get(font: ImageFont.FreeTypeFont) -> None: ] +def test_variation_duplicates() -> None: + font = ImageFont.truetype("Tests/fonts/AdobeVFPrototypeDuplicates.ttf") + assert font.get_variation_names() == [ + b"ExtraLight", + b"Light", + b"Regular", + b"Semibold", + b"Bold", + b"Black", + b"Black Medium Contrast", + b"Black High Contrast", + b"Default", + ] + + def _check_text(font: ImageFont.FreeTypeFont, path: str, epsilon: float) -> None: im = Image.new("RGB", (100, 75), "white") d = ImageDraw.Draw(im) diff --git a/Tests/test_imagemorph.py b/Tests/test_imagemorph.py index ca192a809..daba30015 100644 --- a/Tests/test_imagemorph.py +++ b/Tests/test_imagemorph.py @@ -15,13 +15,10 @@ def string_to_img(image_string: str) -> Image.Image: rows = [s for s in image_string.replace(" ", "").split("\n") if len(s)] height = len(rows) width = len(rows[0]) - im = Image.new("L", (width, height)) - for i in range(width): - for j in range(height): - c = rows[j][i] - v = c in "X1" - im.putpixel((i, j), v) - + im = Image.new("1", (width, height)) + for x in range(width): + for y in range(height): + im.putpixel((x, y), rows[y][x] in "X1") return im @@ -42,10 +39,10 @@ def img_to_string(im: Image.Image) -> str: """Turn a (small) binary image into a string representation""" chars = ".1" result = [] - for r in range(im.height): + for y in range(im.height): line = "" - for c in range(im.width): - value = im.getpixel((c, r)) + for x in range(im.width): + value = im.getpixel((x, y)) assert not isinstance(value, tuple) assert value is not None line += chars[value > 0] @@ -165,10 +162,12 @@ def test_edge() -> None: ) -def test_corner() -> None: +@pytest.mark.parametrize("mode", ("1", "L")) +def test_corner(mode: str) -> None: # Create a corner detector pattern mop = ImageMorph.MorphOp(patterns=["1:(... ... ...)->0", "4:(00. 01. ...)->1"]) - count, Aout = mop.apply(A) + image = A.convert(mode) if mode == "L" else A + count, Aout = mop.apply(image) assert count == 5 assert_img_equal_img_string( Aout, @@ -184,7 +183,7 @@ def test_corner() -> None: ) # Test the coordinate counting with the same operator - coords = mop.match(A) + coords = mop.match(image) assert len(coords) == 4 assert tuple(coords) == ((2, 2), (4, 2), (2, 4), (4, 4)) @@ -232,14 +231,14 @@ def test_negate() -> None: def test_incorrect_mode() -> None: - im = hopper("RGB") + im = hopper() mop = ImageMorph.MorphOp(op_name="erosion8") - with pytest.raises(ValueError, match="Image mode must be L"): + with pytest.raises(ValueError, match="Image mode must be 1 or L"): mop.apply(im) - with pytest.raises(ValueError, match="Image mode must be L"): + with pytest.raises(ValueError, match="Image mode must be 1 or L"): mop.match(im) - with pytest.raises(ValueError, match="Image mode must be L"): + with pytest.raises(ValueError, match="Image mode must be 1 or L"): mop.get_on_pixels(im) @@ -281,6 +280,11 @@ def test_pattern_syntax_error(pattern: str) -> None: lb.build_lut() +def test_build_default_lut() -> None: + lb = ImageMorph.LutBuilder(op_name="corner") + assert lb.build_default_lut() == lb.lut + + def test_load_invalid_mrl() -> None: # Arrange invalid_mrl = "Tests/images/hopper.png" diff --git a/Tests/test_imageops.py b/Tests/test_imageops.py index 63cd0e4d4..35fe3bb8a 100644 --- a/Tests/test_imageops.py +++ b/Tests/test_imageops.py @@ -457,9 +457,9 @@ def test_exif_transpose() -> 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) + im1 = hopper() + im1.getexif()[0x0112] = 3 + transposed_im = ImageOps.exif_transpose(im1) assert 0x0112 not in transposed_im.getexif() diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index f6acb3aff..113d30755 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -20,21 +20,19 @@ TEST_IMAGE_SIZE = (10, 10) def test_numpy_to_image() -> None: def to_image(dtype: npt.DTypeLike, bands: int = 1, boolean: int = 0) -> Image.Image: + data = tuple(range(100)) if bands == 1: if boolean: - data = [0, 255] * 50 - else: - data = list(range(100)) + data = (0, 255) * 50 a = numpy.array(data, dtype=dtype) a.shape = TEST_IMAGE_SIZE i = Image.fromarray(a) - assert list(i.getdata()) == data + assert i.get_flattened_data() == data else: - data = list(range(100)) a = numpy.array([[x] * bands for x in data], dtype=dtype) a.shape = TEST_IMAGE_SIZE[0], TEST_IMAGE_SIZE[1], bands i = Image.fromarray(a) - assert list(i.getchannel(0).getdata()) == list(range(100)) + assert i.get_flattened_data(0) == tuple(range(100)) return i # Check supported 1-bit integer formats @@ -191,7 +189,7 @@ def test_putdata() -> None: arr = numpy.zeros((15000,), numpy.float32) im.putdata(arr) - assert len(im.getdata()) == len(arr) + assert len(im.get_flattened_data()) == len(arr) def test_resize() -> None: @@ -248,7 +246,7 @@ def test_bool() -> None: a[0][0] = True im2 = Image.fromarray(a) - assert im2.getdata()[0] == 255 + assert im2.getpixel((0, 0)) == 255 def test_no_resource_warning_for_numpy_array() -> None: diff --git a/Tests/test_pickle.py b/Tests/test_pickle.py index fc76f81e9..2447ae67a 100644 --- a/Tests/test_pickle.py +++ b/Tests/test_pickle.py @@ -19,30 +19,28 @@ def helper_pickle_file( # Arrange with Image.open(test_file) as im: filename = tmp_path / "temp.pkl" - if mode: - im = im.convert(mode) + converted_im = im.convert(mode) if mode else im # Act with open(filename, "wb") as f: - pickle.dump(im, f, protocol) + pickle.dump(converted_im, f, protocol) with open(filename, "rb") as f: loaded_im = pickle.load(f) # Assert - assert im == loaded_im + assert converted_im == loaded_im 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) + converted_im = im.convert(mode) if mode else im # Act - dumped_string = pickle.dumps(im, protocol) + dumped_string = pickle.dumps(converted_im, protocol) loaded_im = pickle.loads(dumped_string) # Assert - assert im == loaded_im + assert converted_im == loaded_im @pytest.mark.parametrize( diff --git a/docs/deprecations.rst b/docs/deprecations.rst index cc5ac283f..b6a7af0a8 100644 --- a/docs/deprecations.rst +++ b/docs/deprecations.rst @@ -73,6 +73,16 @@ Image._show ``Image._show`` has been deprecated, and will be removed in Pillow 13 (2026-10-15). Use :py:meth:`~PIL.ImageShow.show` instead. +Image getdata() +~~~~~~~~~~~~~~~ + +.. deprecated:: 12.1.0 + +:py:meth:`~PIL.Image.Image.getdata` has been deprecated. +:py:meth:`~PIL.Image.Image.get_flattened_data` can be used instead. This new method is +identical, except that it returns a tuple of pixel values, instead of an internal +Pillow data type. + Removed features ---------------- diff --git a/docs/example/DdsImagePlugin.py b/docs/example/DdsImagePlugin.py index e4b6b9c01..e0557976c 100644 --- a/docs/example/DdsImagePlugin.py +++ b/docs/example/DdsImagePlugin.py @@ -213,6 +213,7 @@ class DdsImageFile(ImageFile.ImageFile): format_description = "DirectDraw Surface" def _open(self) -> None: + assert self.fp is not None if not _accept(self.fp.read(4)): msg = "not a DDS file" raise SyntaxError(msg) diff --git a/docs/handbook/image-file-formats.rst b/docs/handbook/image-file-formats.rst index 03ee96c0f..35ec99ece 100644 --- a/docs/handbook/image-file-formats.rst +++ b/docs/handbook/image-file-formats.rst @@ -999,7 +999,7 @@ where applicable: The number of times to loop this APNG, 0 indicates infinite looping. **duration** - The time to display this APNG frame (in milliseconds). + The time to display this APNG frame (in milliseconds), given as a float. .. note:: @@ -1041,9 +1041,8 @@ following parameters can also be set: Defaults to 0. **duration** - Integer (or list or tuple of integers) length of time to display this APNG frame - (in milliseconds). - Defaults to 0. + The length of time (or list or tuple of lengths of time) to display this APNG frame + (in milliseconds). Defaults to 0. **disposal** An integer (or list or tuple of integers) specifying the APNG disposal diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 17e38719a..ee70d8401 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -53,8 +53,8 @@ These platforms are built and tested for every change. | | | s390x | +----------------------------------+----------------------------+---------------------+ | Windows Server 2022 | 3.10 | x86 | -| +----------------------------+---------------------+ -| | 3.11, 3.12, 3.13, 3.14, | x86-64 | ++----------------------------------+----------------------------+---------------------+ +| Windows Server 2025 | 3.11, 3.12, 3.13, 3.14, | x86-64 | | | PyPy3 | | | +----------------------------+---------------------+ | | 3.12 (MinGW) | x86-64 | diff --git a/docs/reference/Image.rst b/docs/reference/Image.rst index e68722900..adee49228 100644 --- a/docs/reference/Image.rst +++ b/docs/reference/Image.rst @@ -191,6 +191,7 @@ This helps to get the bounding box coordinates of the input image:: .. automethod:: PIL.Image.Image.getchannel .. automethod:: PIL.Image.Image.getcolors .. automethod:: PIL.Image.Image.getdata +.. automethod:: PIL.Image.Image.get_flattened_data .. automethod:: PIL.Image.Image.getexif .. automethod:: PIL.Image.Image.getextrema .. automethod:: PIL.Image.Image.getpalette diff --git a/docs/reference/ImageMorph.rst b/docs/reference/ImageMorph.rst index 30b89a54d..f7a302713 100644 --- a/docs/reference/ImageMorph.rst +++ b/docs/reference/ImageMorph.rst @@ -4,10 +4,50 @@ :py:mod:`~PIL.ImageMorph` module ================================ -The :py:mod:`~PIL.ImageMorph` module provides morphology operations on images. +The :py:mod:`~PIL.ImageMorph` module allows `morphology`_ operators ("MorphOp") to be +applied to 1 or L mode images:: -.. automodule:: PIL.ImageMorph + from PIL import Image, ImageMorph + img = Image.open("Tests/images/hopper.bw") + mop = ImageMorph.MorphOp(op_name="erosion4") + count, imgOut = mop.apply(img) + imgOut.show() + +.. _morphology: https://en.wikipedia.org/wiki/Mathematical_morphology + +In addition to applying operators, you can also analyse images. + +You can inspect an image in isolation to determine which pixels are non-empty:: + + print(mop.get_on_pixels(img)) # [(0, 0), (1, 0), (2, 0), ...] + +Or you can retrieve a list of pixels that match the operator. This is the number of +pixels that will be non-empty after the operator is applied:: + + coords = mop.match(img) + print(coords) # [(17, 1), (18, 1), (34, 1), ...] + print(len(coords)) # 550 + + imgOut = mop.apply(img)[1] + print(len(mop.get_on_pixels(imgOut))) # 550 + +If you would like more customized operators, you can pass patterns to the MorphOp +class:: + + mop = ImageMorph.MorphOp(patterns=["1:(... ... ...)->0", "4:(00. 01. ...)->1"]) + +Or you can pass lookup table ("LUT") data directly. This LUT data can be constructed +with the :py:class:`~PIL.ImageMorph.LutBuilder`:: + + builder = ImageMorph.LutBuilder() + mop = ImageMorph.MorphOp(lut=builder.build_lut()) + +.. autoclass:: LutBuilder + :members: + :undoc-members: + :show-inheritance: + +.. autoclass:: MorphOp :members: :undoc-members: :show-inheritance: - :noindex: diff --git a/docs/releasenotes/12.1.0.rst b/docs/releasenotes/12.1.0.rst index b6e1810c6..9740b7008 100644 --- a/docs/releasenotes/12.1.0.rst +++ b/docs/releasenotes/12.1.0.rst @@ -1,46 +1,36 @@ 12.1.0 ------ -Security -======== - -TODO -^^^^ - -TODO - -:cve:`YYYY-XXXXX`: TODO -^^^^^^^^^^^^^^^^^^^^^^^ - -TODO - -Backwards incompatible changes -============================== - -TODO -^^^^ - -TODO - Deprecations ============ -TODO -^^^^ +Image getdata() +^^^^^^^^^^^^^^^ -TODO +:py:meth:`~PIL.Image.Image.getdata` has been deprecated. +:py:meth:`~PIL.Image.Image.get_flattened_data` can be used instead. This new method is +identical, except that it returns a tuple of pixel values, instead of an internal +Pillow data type. API changes =========== -TODO -^^^^ +ImageMorph build_default_lut() +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +To match the behaviour of :py:meth:`~PIL.ImageMorph.LutBuilder.build_lut`, +:py:meth:`~PIL.ImageMorph.LutBuilder.build_default_lut()` now returns the new LUT. API additions ============= +Image get_flattened_data() +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +:py:meth:`~PIL.Image.Image.get_flattened_data` is identical to the deprecated +:py:meth:`~PIL.Image.Image.getdata`, except that the new method returns a tuple of +pixel values, instead of an internal Pillow data type. + Specify window in ImageGrab on macOS ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -53,7 +43,7 @@ macOS in addition to Windows. On macOS, this is a CGWindowID:: Other changes ============= -TODO -^^^^ +Added MorphOp support for 1 mode images +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -TODO +:py:class:`~PIL.ImageMorph.MorphOp` now supports both 1 mode and L mode images. diff --git a/docs/releasenotes/index.rst b/docs/releasenotes/index.rst index b097770a3..4b25bb6a2 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 + versioning 12.1.0 12.0.0 11.3.0 @@ -80,4 +81,3 @@ expected to be backported to earlier versions. 2.5.2 2.3.2 2.3.1 - versioning diff --git a/docs/releasenotes/versioning.rst b/docs/releasenotes/versioning.rst index 2a0af9e59..884102d16 100644 --- a/docs/releasenotes/versioning.rst +++ b/docs/releasenotes/versioning.rst @@ -17,8 +17,8 @@ prior three months. A quarterly release bumps the MAJOR version when incompatible API changes are made, such as removing deprecated APIs or dropping an EOL Python version. In practice, -these occur every 12-18 months, guided by -`Python's EOL schedule `_, and +these occur every October, guided by +`Python's EOL schedule `__, and any APIs that have been deprecated for at least a year are removed at the same time. PATCH versions ("`Point Release `_" diff --git a/src/PIL/AvifImagePlugin.py b/src/PIL/AvifImagePlugin.py index 366e0c864..43c39a9fb 100644 --- a/src/PIL/AvifImagePlugin.py +++ b/src/PIL/AvifImagePlugin.py @@ -77,6 +77,8 @@ class AvifImageFile(ImageFile.ImageFile): ): msg = "Invalid opening codec" raise ValueError(msg) + + assert self.fp is not None self._decoder = _avif.AvifDecoder( self.fp.read(), DECODE_CODEC_CHOICE, diff --git a/src/PIL/BlpImagePlugin.py b/src/PIL/BlpImagePlugin.py index f7be7746d..6bb92edf8 100644 --- a/src/PIL/BlpImagePlugin.py +++ b/src/PIL/BlpImagePlugin.py @@ -258,6 +258,7 @@ class BlpImageFile(ImageFile.ImageFile): format_description = "Blizzard Mipmap Format" def _open(self) -> None: + assert self.fp is not None self.magic = self.fp.read(4) if not _accept(self.magic): msg = f"Bad BLP magic {repr(self.magic)}" diff --git a/src/PIL/BmpImagePlugin.py b/src/PIL/BmpImagePlugin.py index 54fc69ab4..a12271370 100644 --- a/src/PIL/BmpImagePlugin.py +++ b/src/PIL/BmpImagePlugin.py @@ -76,6 +76,7 @@ class BmpImageFile(ImageFile.ImageFile): def _bitmap(self, header: int = 0, offset: int = 0) -> None: """Read relevant info about the BMP""" + assert self.fp is not None read, seek = self.fp.read, self.fp.seek if header: seek(header) @@ -311,6 +312,7 @@ class BmpImageFile(ImageFile.ImageFile): def _open(self) -> None: """Open file, check magic number and read header""" # read 14 bytes: magic number, filesize, reserved, header final offset + assert self.fp is not None head_data = self.fp.read(14) # choke if the file does not have the required magic bytes if not _accept(head_data): diff --git a/src/PIL/BufrStubImagePlugin.py b/src/PIL/BufrStubImagePlugin.py index 8c5da14f5..264564d2b 100644 --- a/src/PIL/BufrStubImagePlugin.py +++ b/src/PIL/BufrStubImagePlugin.py @@ -41,6 +41,7 @@ class BufrStubImageFile(ImageFile.StubImageFile): format_description = "BUFR" def _open(self) -> None: + assert self.fp is not None if not _accept(self.fp.read(4)): msg = "Not a BUFR file" raise SyntaxError(msg) diff --git a/src/PIL/DcxImagePlugin.py b/src/PIL/DcxImagePlugin.py index aea661b9c..d3f456ddc 100644 --- a/src/PIL/DcxImagePlugin.py +++ b/src/PIL/DcxImagePlugin.py @@ -45,6 +45,7 @@ class DcxImageFile(PcxImageFile): def _open(self) -> None: # Header + assert self.fp is not None s = self.fp.read(4) if not _accept(s): msg = "not a DCX file" diff --git a/src/PIL/EpsImagePlugin.py b/src/PIL/EpsImagePlugin.py index 69f3062b4..2effb816c 100644 --- a/src/PIL/EpsImagePlugin.py +++ b/src/PIL/EpsImagePlugin.py @@ -189,6 +189,7 @@ class EpsImageFile(ImageFile.ImageFile): mode_map = {1: "L", 2: "LAB", 3: "RGB", 4: "CMYK"} def _open(self) -> None: + assert self.fp is not None (length, offset) = self._find_offset(self.fp) # go to offset - start of "%!PS" @@ -403,6 +404,7 @@ class EpsImageFile(ImageFile.ImageFile): ) -> Image.core.PixelAccess | None: # Load EPS via Ghostscript if self.tile: + assert self.fp is not None self.im = Ghostscript(self.tile, self.size, self.fp, scale, transparency) self._mode = self.im.mode self._size = self.im.size diff --git a/src/PIL/FpxImagePlugin.py b/src/PIL/FpxImagePlugin.py index fd992cd9e..297971234 100644 --- a/src/PIL/FpxImagePlugin.py +++ b/src/PIL/FpxImagePlugin.py @@ -58,6 +58,7 @@ class FpxImageFile(ImageFile.ImageFile): # read the OLE directory and see if this is a likely # to be a FlashPix file + assert self.fp is not None try: self.ole = olefile.OleFileIO(self.fp) except OSError as e: @@ -229,6 +230,7 @@ class FpxImageFile(ImageFile.ImageFile): if y >= ysize: break # isn't really required + assert self.fp is not None self.stream = stream self._fp = self.fp self.fp = None diff --git a/src/PIL/FtexImagePlugin.py b/src/PIL/FtexImagePlugin.py index d60e75bb6..e4d836cbd 100644 --- a/src/PIL/FtexImagePlugin.py +++ b/src/PIL/FtexImagePlugin.py @@ -72,6 +72,7 @@ class FtexImageFile(ImageFile.ImageFile): format_description = "Texture File Format (IW2:EOC)" def _open(self) -> None: + assert self.fp is not None if not _accept(self.fp.read(4)): msg = "not an FTEX file" raise SyntaxError(msg) diff --git a/src/PIL/GbrImagePlugin.py b/src/PIL/GbrImagePlugin.py index d69295363..ec666c81c 100644 --- a/src/PIL/GbrImagePlugin.py +++ b/src/PIL/GbrImagePlugin.py @@ -42,6 +42,7 @@ class GbrImageFile(ImageFile.ImageFile): format_description = "GIMP brush file" def _open(self) -> None: + assert self.fp is not None header_size = i32(self.fp.read(4)) if header_size < 20: msg = "not a GIMP brush" @@ -88,6 +89,7 @@ class GbrImageFile(ImageFile.ImageFile): def load(self) -> Image.core.PixelAccess | None: if self._im is None: + assert self.fp is not None self.im = Image.core.new(self.mode, self.size) self.frombytes(self.fp.read(self._data_size)) return Image.Image.load(self) diff --git a/src/PIL/GifImagePlugin.py b/src/PIL/GifImagePlugin.py index 0560a5a7d..76a0d4ab9 100644 --- a/src/PIL/GifImagePlugin.py +++ b/src/PIL/GifImagePlugin.py @@ -87,6 +87,7 @@ class GifImageFile(ImageFile.ImageFile): global_palette = None def data(self) -> bytes | None: + assert self.fp is not None s = self.fp.read(1) if s and s[0]: return self.fp.read(s[0]) @@ -100,6 +101,7 @@ class GifImageFile(ImageFile.ImageFile): def _open(self) -> None: # Screen + assert self.fp is not None s = self.fp.read(13) if not _accept(s): msg = "not a GIF file" @@ -751,7 +753,7 @@ def _write_multiple_frames( if delta.mode == "P": # Convert to L without considering palette delta_l = Image.new("L", delta.size) - delta_l.putdata(delta.getdata()) + delta_l.putdata(delta.get_flattened_data()) delta = delta_l mask = ImageMath.lambda_eval( lambda args: args["convert"](args["im"] * 255, "1"), diff --git a/src/PIL/GribStubImagePlugin.py b/src/PIL/GribStubImagePlugin.py index dfa798893..146a6fa0d 100644 --- a/src/PIL/GribStubImagePlugin.py +++ b/src/PIL/GribStubImagePlugin.py @@ -41,6 +41,7 @@ class GribStubImageFile(ImageFile.StubImageFile): format_description = "GRIB" def _open(self) -> None: + assert self.fp is not None if not _accept(self.fp.read(8)): msg = "Not a GRIB file" raise SyntaxError(msg) diff --git a/src/PIL/Hdf5StubImagePlugin.py b/src/PIL/Hdf5StubImagePlugin.py index 76e640f15..1523e95d5 100644 --- a/src/PIL/Hdf5StubImagePlugin.py +++ b/src/PIL/Hdf5StubImagePlugin.py @@ -41,6 +41,7 @@ class HDF5StubImageFile(ImageFile.StubImageFile): format_description = "HDF5" def _open(self) -> None: + assert self.fp is not None if not _accept(self.fp.read(8)): msg = "Not an HDF file" raise SyntaxError(msg) diff --git a/src/PIL/IcnsImagePlugin.py b/src/PIL/IcnsImagePlugin.py index 197ea7a2b..058861d67 100644 --- a/src/PIL/IcnsImagePlugin.py +++ b/src/PIL/IcnsImagePlugin.py @@ -265,6 +265,7 @@ class IcnsImageFile(ImageFile.ImageFile): format_description = "Mac OS icns resource" def _open(self) -> None: + assert self.fp is not None self.icns = IcnsFile(self.fp) self._mode = "RGBA" self.info["sizes"] = self.icns.itersizes() diff --git a/src/PIL/IcoImagePlugin.py b/src/PIL/IcoImagePlugin.py index d5da07d47..8dd57ff85 100644 --- a/src/PIL/IcoImagePlugin.py +++ b/src/PIL/IcoImagePlugin.py @@ -340,6 +340,7 @@ class IcoImageFile(ImageFile.ImageFile): format_description = "Windows Icon" def _open(self) -> None: + assert self.fp is not None self.ico = IcoFile(self.fp) self.info["sizes"] = self.ico.sizes() self.size = self.ico.entry[0].dim diff --git a/src/PIL/ImImagePlugin.py b/src/PIL/ImImagePlugin.py index 71b999678..ef54f16e9 100644 --- a/src/PIL/ImImagePlugin.py +++ b/src/PIL/ImImagePlugin.py @@ -125,6 +125,7 @@ class ImImageFile(ImageFile.ImageFile): # Quick rejection: if there's not an LF among the first # 100 bytes, this is (probably) not a text header. + assert self.fp is not None if b"\n" not in self.fp.read(100): msg = "not an IM file" raise SyntaxError(msg) diff --git a/src/PIL/Image.py b/src/PIL/Image.py index b71395c62..57ebea689 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -590,16 +590,11 @@ class Image: return new # Context manager support - def __enter__(self): + def __enter__(self) -> Image: return self - def __exit__(self, *args): - from . import ImageFile - - if isinstance(self, ImageFile.ImageFile): - if getattr(self, "_exclusive_fp", False): - self._close_fp() - self.fp = None + def __exit__(self, *args: object) -> None: + pass def close(self) -> None: """ @@ -1440,12 +1435,31 @@ class Image: value (e.g. 0 to get the "R" band from an "RGB" image). :returns: A sequence-like object. """ + deprecate("Image.Image.getdata", 14, "get_flattened_data") self.load() if band is not None: return self.im.getband(band) return self.im # could be abused + def get_flattened_data( + self, band: int | None = None + ) -> tuple[tuple[int, ...], ...] | tuple[float, ...]: + """ + Returns the contents of this image as a tuple containing pixel values. + The sequence object is flattened, so that values for line one follow + directly after the values of line zero, and so on. + + :param band: What band to return. The default is to return + all bands. To return a single band, pass in the index + value (e.g. 0 to get the "R" band from an "RGB" image). + :returns: A tuple containing pixel values. + """ + self.load() + if band is not None: + return tuple(self.im.getband(band)) + return tuple(self.im) + def getextrema(self) -> tuple[float, float] | tuple[tuple[int, int], ...]: """ Gets the minimum and maximum pixel values for each band in @@ -1524,6 +1538,8 @@ class Image: assert isinstance(self, TiffImagePlugin.TiffImageFile) self._exif.bigtiff = self.tag_v2._bigtiff self._exif.endian = self.tag_v2._endian + + assert self.fp is not None self._exif.load_from_fp(self.fp, self.tag_v2._offset) if exif_info is not None: self._exif.load(exif_info) diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index a1d98bd51..3390dfa97 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -131,6 +131,8 @@ class ImageFile(Image.Image): self.decoderconfig: tuple[Any, ...] = () self.decodermaxblock = MAXBLOCK + self.fp: IO[bytes] | None + self._fp: IO[bytes] | DeferredError if is_path(fp): # filename self.fp = open(fp, "rb") @@ -167,7 +169,11 @@ class ImageFile(Image.Image): def _open(self) -> None: pass - def _close_fp(self): + # Context manager support + def __enter__(self) -> ImageFile: + return self + + def _close_fp(self) -> None: if getattr(self, "_fp", False) and not isinstance(self._fp, DeferredError): if self._fp != self.fp: self._fp.close() @@ -175,6 +181,11 @@ class ImageFile(Image.Image): if self.fp: self.fp.close() + def __exit__(self, *args: object) -> None: + if getattr(self, "_exclusive_fp", False): + self._close_fp() + self.fp = None + def close(self) -> None: """ Closes the file pointer, if possible. @@ -267,7 +278,7 @@ class ImageFile(Image.Image): # raise exception if something's wrong. must be called # directly after open, and closes file when finished. - if self._exclusive_fp: + if self._exclusive_fp and self.fp: self.fp.close() self.fp = None @@ -285,6 +296,7 @@ class ImageFile(Image.Image): self.map: mmap.mmap | None = None use_mmap = self.filename and len(self.tile) == 1 + assert self.fp is not None readonly = 0 # look for read/seek overrides diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index cc5ac67d9..df4ea5f2e 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -685,8 +685,12 @@ class FreeTypeFont: :returns: A list of the named styles in a variation font. :exception OSError: If the font is not a variation font. """ - names = self.font.getvarnames() - return [name.replace(b"\x00", b"") for name in names] + names = [] + for name in self.font.getvarnames(): + name = name.replace(b"\x00", b"") + if name not in names: + names.append(name) + return names def set_variation_by_name(self, name: str | bytes) -> None: """ diff --git a/src/PIL/ImageMorph.py b/src/PIL/ImageMorph.py index bd70aff7b..9fcd8d78d 100644 --- a/src/PIL/ImageMorph.py +++ b/src/PIL/ImageMorph.py @@ -65,10 +65,12 @@ class LutBuilder: 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 = [] + """ + :param patterns: A list of input patterns, or None. + :param op_name: The name of a known pattern. One of "corner", "dilation4", + "dilation8", "erosion4", "erosion8" or "edge". + :exception Exception: If the op_name is not recognized. + """ self.lut: bytearray | None = None if op_name is not None: known_patterns = { @@ -88,20 +90,38 @@ class LutBuilder: raise Exception(msg) self.patterns = known_patterns[op_name] + elif patterns is not None: + self.patterns = patterns + else: + self.patterns = [] def add_patterns(self, patterns: list[str]) -> None: + """ + Append to list of patterns. + + :param patterns: Additional patterns. + """ self.patterns += patterns - def build_default_lut(self) -> None: + def build_default_lut(self) -> bytearray: + """ + Set the current LUT, and return it. + + This is the default LUT that patterns will be applied against when building. + """ symbols = [0, 1] m = 1 << 4 # pos of current pixel self.lut = bytearray(symbols[(i & m) > 0] for i in range(LUT_SIZE)) + return self.lut def get_lut(self) -> bytearray | None: + """ + Returns the current LUT + """ return self.lut def _string_permute(self, pattern: str, permutation: list[int]) -> str: - """string_permute takes a pattern and a permutation and returns the + """Takes a pattern and a permutation and returns the string permuted according to the permutation list. """ assert len(permutation) == 9 @@ -110,7 +130,7 @@ class LutBuilder: 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 + """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.""" patterns = [(basic_pattern, basic_result)] @@ -140,10 +160,9 @@ class LutBuilder: return patterns def build_lut(self) -> bytearray: - """Compile all patterns into a morphology lut. + """Compile all patterns into a morphology LUT, and return it. - TBD :Build based on (file) morphlut:modify_lut - """ + This is the data to be passed into MorphOp.""" self.build_default_lut() assert self.lut is not None patterns = [] @@ -163,15 +182,14 @@ class LutBuilder: patterns += self._pattern_permute(pattern, options, result) - # compile the patterns into regular expressions for speed + # Compile the patterns into regular expressions for speed compiled_patterns = [] for pattern in patterns: p = pattern[0].replace(".", "X").replace("X", "[01]") 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 - # caught overrides + # Note that all the patterns are searched. The last one found takes priority for i in range(LUT_SIZE): # Build the bit pattern bitpattern = bin(i)[2:] @@ -193,26 +211,39 @@ class MorphOp: 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: - self.lut = LutBuilder(op_name=op_name).build_lut() - elif patterns is not None: - self.lut = LutBuilder(patterns=patterns).build_lut() + """Create a binary morphological operator. + + If the LUT is not provided, then it is built using LutBuilder from the op_name + or the patterns. + + :param lut: The LUT data. + :param patterns: A list of input patterns, or None. + :param op_name: The name of a known pattern. One of "corner", "dilation4", + "dilation8", "erosion4", "erosion8", "edge". + :exception Exception: If the op_name is not recognized. + """ + if patterns is None and op_name is None: + self.lut = lut + else: + self.lut = LutBuilder(patterns, op_name).build_lut() def apply(self, image: Image.Image) -> tuple[int, Image.Image]: - """Run a single morphological operation on an image + """Run a single morphological operation on an image. Returns a tuple of the number of changed pixels and the - morphed image""" + morphed image. + + :param image: A 1-mode or L-mode image. + :exception Exception: If the current operator is None. + :exception ValueError: If the image is not 1 or L mode.""" if self.lut is None: msg = "No operator loaded" raise Exception(msg) - if image.mode != "L": - msg = "Image mode must be L" + if image.mode not in ("1", "L"): + msg = "Image mode must be 1 or L" raise ValueError(msg) - outimage = Image.new(image.mode, image.size, None) + outimage = Image.new(image.mode, image.size) count = _imagingmorph.apply(bytes(self.lut), image.getim(), outimage.getim()) return count, outimage @@ -220,30 +251,42 @@ class MorphOp: """Get a list of coordinates matching the morphological operation on an image. - Returns a list of tuples of (x,y) coordinates - of all matching pixels. See :ref:`coordinate-system`.""" + Returns a list of tuples of (x,y) coordinates of all matching pixels. See + :ref:`coordinate-system`. + + :param image: A 1-mode or L-mode image. + :exception Exception: If the current operator is None. + :exception ValueError: If the image is not 1 or L mode.""" if self.lut is None: msg = "No operator loaded" raise Exception(msg) - if image.mode != "L": - msg = "Image mode must be L" + if image.mode not in ("1", "L"): + msg = "Image mode must be 1 or L" raise ValueError(msg) return _imagingmorph.match(bytes(self.lut), image.getim()) def get_on_pixels(self, image: Image.Image) -> list[tuple[int, int]]: - """Get a list of all turned on pixels in a binary image + """Get a list of all turned on pixels in a 1 or L mode image. - Returns a list of tuples of (x,y) coordinates - of all matching pixels. See :ref:`coordinate-system`.""" + Returns a list of tuples of (x,y) coordinates of all non-empty pixels. See + :ref:`coordinate-system`. - if image.mode != "L": - msg = "Image mode must be L" + :param image: A 1-mode or L-mode image. + :exception ValueError: If the image is not 1 or L mode.""" + + if image.mode not in ("1", "L"): + msg = "Image mode must be 1 or L" raise ValueError(msg) return _imagingmorph.get_on_pixels(image.getim()) def load_lut(self, filename: str) -> None: - """Load an operator from an mrl file""" + """ + Load an operator from an mrl file + + :param filename: The file to read from. + :exception Exception: If the length of the file data is not 512. + """ with open(filename, "rb") as f: self.lut = bytearray(f.read()) @@ -253,7 +296,12 @@ class MorphOp: raise Exception(msg) def save_lut(self, filename: str) -> None: - """Save an operator to an mrl file""" + """ + Save an operator to an mrl file. + + :param filename: The destination file. + :exception Exception: If the current operator is None. + """ if self.lut is None: msg = "No operator loaded" raise Exception(msg) @@ -261,5 +309,9 @@ class MorphOp: f.write(self.lut) def set_lut(self, lut: bytearray | None) -> None: - """Set the lut from an external source""" + """ + Set the LUT from an external source + + :param lut: A new LUT. + """ self.lut = lut diff --git a/src/PIL/IptcImagePlugin.py b/src/PIL/IptcImagePlugin.py index c28f4dcc7..6fc824e4c 100644 --- a/src/PIL/IptcImagePlugin.py +++ b/src/PIL/IptcImagePlugin.py @@ -49,6 +49,7 @@ class IptcImageFile(ImageFile.ImageFile): def field(self) -> tuple[tuple[int, int] | None, int]: # # get a IPTC field header + assert self.fp is not None s = self.fp.read(5) if not s.strip(b"\x00"): return None, 0 @@ -76,6 +77,7 @@ class IptcImageFile(ImageFile.ImageFile): def _open(self) -> None: # load descriptive fields + assert self.fp is not None while True: offset = self.fp.tell() tag, size = self.field() @@ -131,6 +133,7 @@ class IptcImageFile(ImageFile.ImageFile): assert isinstance(args, tuple) compression, band = args + assert self.fp is not None self.fp.seek(self.tile[0].offset) # Copy image data to temporary file @@ -154,10 +157,11 @@ class IptcImageFile(ImageFile.ImageFile): if band is not None: bands = [Image.new("L", _im.size)] * Image.getmodebands(self.mode) bands[band] = _im - _im = Image.merge(self.mode, bands) + im = Image.merge(self.mode, bands) else: - _im.load() - self.im = _im.im + im = _im + im.load() + self.im = im.im self.tile = [] return ImageFile.ImageFile.load(self) diff --git a/src/PIL/Jpeg2KImagePlugin.py b/src/PIL/Jpeg2KImagePlugin.py index 4c85dd4e2..d6ec38d43 100644 --- a/src/PIL/Jpeg2KImagePlugin.py +++ b/src/PIL/Jpeg2KImagePlugin.py @@ -252,6 +252,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): format_description = "JPEG 2000 (ISO 15444)" def _open(self) -> None: + assert self.fp is not None sig = self.fp.read(4) if sig == b"\xff\x4f\xff\x51": self.codec = "j2k" @@ -304,6 +305,7 @@ class Jpeg2KImageFile(ImageFile.ImageFile): ] def _parse_comment(self) -> None: + assert self.fp is not None while True: marker = self.fp.read(2) if not marker: diff --git a/src/PIL/JpegImagePlugin.py b/src/PIL/JpegImagePlugin.py index 755ca648e..894c1547d 100644 --- a/src/PIL/JpegImagePlugin.py +++ b/src/PIL/JpegImagePlugin.py @@ -61,6 +61,7 @@ if TYPE_CHECKING: def Skip(self: JpegImageFile, marker: int) -> None: + assert self.fp is not None n = i16(self.fp.read(2)) - 2 ImageFile._safe_read(self.fp, n) @@ -70,6 +71,7 @@ def APP(self: JpegImageFile, marker: int) -> None: # Application marker. Store these in the APP dictionary. # Also look for well-known application markers. + assert self.fp is not None n = i16(self.fp.read(2)) - 2 s = ImageFile._safe_read(self.fp, n) @@ -174,6 +176,7 @@ def APP(self: JpegImageFile, marker: int) -> None: def COM(self: JpegImageFile, marker: int) -> None: # # Comment marker. Store these in the APP dictionary. + assert self.fp is not None n = i16(self.fp.read(2)) - 2 s = ImageFile._safe_read(self.fp, n) @@ -190,6 +193,7 @@ def SOF(self: JpegImageFile, marker: int) -> None: # mode. Note that this could be made a bit brighter, by # looking for JFIF and Adobe APP markers. + assert self.fp is not None n = i16(self.fp.read(2)) - 2 s = ImageFile._safe_read(self.fp, n) self._size = i16(s, 3), i16(s, 1) @@ -240,6 +244,7 @@ def DQT(self: JpegImageFile, marker: int) -> None: # FIXME: The quantization tables can be used to estimate the # compression quality. + assert self.fp is not None n = i16(self.fp.read(2)) - 2 s = ImageFile._safe_read(self.fp, n) while len(s): @@ -340,6 +345,7 @@ class JpegImageFile(ImageFile.ImageFile): format_description = "JPEG (ISO 10918)" def _open(self) -> None: + assert self.fp is not None s = self.fp.read(3) if not _accept(s): @@ -408,6 +414,7 @@ class JpegImageFile(ImageFile.ImageFile): For premature EOF and LOAD_TRUNCATED_IMAGES adds EOI marker so libjpeg can finish decoding """ + assert self.fp is not None s = self.fp.read(read_bytes) if not s and ImageFile.LOAD_TRUNCATED_IMAGES and not hasattr(self, "_ended"): diff --git a/src/PIL/MicImagePlugin.py b/src/PIL/MicImagePlugin.py index 9ce38c427..99a07bae0 100644 --- a/src/PIL/MicImagePlugin.py +++ b/src/PIL/MicImagePlugin.py @@ -67,6 +67,7 @@ class MicImageFile(TiffImagePlugin.TiffImageFile): self._n_frames = len(self.images) self.is_animated = self._n_frames > 1 + assert self.fp is not None self.__fp = self.fp self.seek(0) diff --git a/src/PIL/MpoImagePlugin.py b/src/PIL/MpoImagePlugin.py index b1ae07873..9360061ba 100644 --- a/src/PIL/MpoImagePlugin.py +++ b/src/PIL/MpoImagePlugin.py @@ -106,6 +106,7 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile): _close_exclusive_fp_after_loading = False def _open(self) -> None: + assert self.fp is not None self.fp.seek(0) # prep the fp in order to pass the JPEG test JpegImagePlugin.JpegImageFile._open(self) self._after_jpeg_open() @@ -125,6 +126,7 @@ class MpoImageFile(JpegImagePlugin.JpegImageFile): assert self.n_frames == len(self.__mpoffsets) del self.info["mpoffset"] # no longer needed self.is_animated = self.n_frames > 1 + assert self.fp is not None self._fp = self.fp # FIXME: hack self._fp.seek(self.__mpoffsets[0]) # get ready to read first frame self.__frame = 0 diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index 967308221..9826a4cd1 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -39,6 +39,7 @@ import struct import warnings import zlib from enum import IntEnum +from fractions import Fraction from typing import IO, NamedTuple, cast from . import Image, ImageChops, ImageFile, ImagePalette, ImageSequence @@ -759,6 +760,7 @@ class PngImageFile(ImageFile.ImageFile): format_description = "Portable network graphics" def _open(self) -> None: + assert self.fp is not None if not _accept(self.fp.read(8)): msg = "not a PNG file" raise SyntaxError(msg) @@ -855,9 +857,7 @@ class PngImageFile(ImageFile.ImageFile): self.png.verify() self.png.close() - if self._exclusive_fp: - self.fp.close() - self.fp = None + super().verify() def seek(self, frame: int) -> None: if not self._seek_check(frame): @@ -990,6 +990,7 @@ class PngImageFile(ImageFile.ImageFile): """internal: read more image data""" assert self.png is not None + assert self.fp is not None while self.__idat == 0: # end of chunk, skip forward to next one @@ -1023,6 +1024,7 @@ class PngImageFile(ImageFile.ImageFile): def load_end(self) -> None: """internal: finished reading image data""" assert self.png is not None + assert self.fp is not None if self.__idat != 0: self.fp.read(self.__idat) while True: @@ -1274,7 +1276,11 @@ def _write_multiple_frames( im_frame = im_frame.crop(bbox) size = im_frame.size encoderinfo = frame_data.encoderinfo - frame_duration = int(round(encoderinfo.get("duration", 0))) + frame_duration = encoderinfo.get("duration", 0) + delay = Fraction(frame_duration / 1000).limit_denominator(65535) + if delay.numerator > 65535: + msg = "cannot write duration" + raise ValueError(msg) frame_disposal = encoderinfo.get("disposal", disposal) frame_blend = encoderinfo.get("blend", blend) # frame control @@ -1286,8 +1292,8 @@ def _write_multiple_frames( o32(size[1]), # height o32(bbox[0]), # x_offset o32(bbox[1]), # y_offset - o16(frame_duration), # delay_numerator - o16(1000), # delay_denominator + o16(delay.numerator), # delay_numerator + o16(delay.denominator), # delay_denominator o8(frame_disposal), # dispose_op o8(frame_blend), # blend_op ) diff --git a/src/PIL/PsdImagePlugin.py b/src/PIL/PsdImagePlugin.py index f49aaeeb1..69a8703dd 100644 --- a/src/PIL/PsdImagePlugin.py +++ b/src/PIL/PsdImagePlugin.py @@ -61,6 +61,7 @@ class PsdImageFile(ImageFile.ImageFile): _close_exclusive_fp_after_loading = False def _open(self) -> None: + assert self.fp is not None read = self.fp.read # diff --git a/src/PIL/QoiImagePlugin.py b/src/PIL/QoiImagePlugin.py index dba5d809f..d0709b119 100644 --- a/src/PIL/QoiImagePlugin.py +++ b/src/PIL/QoiImagePlugin.py @@ -25,6 +25,7 @@ class QoiImageFile(ImageFile.ImageFile): format_description = "Quite OK Image" def _open(self) -> None: + assert self.fp is not None if not _accept(self.fp.read(4)): msg = "not a QOI file" raise SyntaxError(msg) diff --git a/src/PIL/SpiderImagePlugin.py b/src/PIL/SpiderImagePlugin.py index 868019e80..866292243 100644 --- a/src/PIL/SpiderImagePlugin.py +++ b/src/PIL/SpiderImagePlugin.py @@ -104,6 +104,7 @@ class SpiderImageFile(ImageFile.ImageFile): def _open(self) -> None: # check header n = 27 * 4 # read 27 float values + assert self.fp is not None f = self.fp.read(n) try: @@ -323,9 +324,9 @@ if __name__ == "__main__": outfile = sys.argv[2] # perform some image operation - im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) + transposed_im = im.transpose(Image.Transpose.FLIP_LEFT_RIGHT) print( f"saving a flipped version of {os.path.basename(filename)} " f"as {outfile} " ) - im.save(outfile, SpiderImageFile.format) + transposed_im.save(outfile, SpiderImageFile.format) diff --git a/src/PIL/WalImageFile.py b/src/PIL/WalImageFile.py index 5494f62e8..fb3e1c06a 100644 --- a/src/PIL/WalImageFile.py +++ b/src/PIL/WalImageFile.py @@ -39,6 +39,7 @@ class WalImageFile(ImageFile.ImageFile): self._mode = "P" # read header fields + assert self.fp is not None header = self.fp.read(32 + 24 + 32 + 12) self._size = i32(header, 32), i32(header, 36) Image._decompression_bomb_check(self.size) @@ -54,6 +55,7 @@ class WalImageFile(ImageFile.ImageFile): def load(self) -> Image.core.PixelAccess | None: if self._im is None: + assert self.fp is not None self.im = Image.core.new(self.mode, self.size) self.frombytes(self.fp.read(self.size[0] * self.size[1])) self.putpalette(quake2palette) diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py index 2847fed20..e20e40d91 100644 --- a/src/PIL/WebPImagePlugin.py +++ b/src/PIL/WebPImagePlugin.py @@ -45,33 +45,30 @@ class WebPImageFile(ImageFile.ImageFile): def _open(self) -> None: # Use the newer AnimDecoder API to parse the (possibly) animated file, # and access muxed chunks like ICC/EXIF/XMP. + assert self.fp is not None self._decoder = _webp.WebPAnimDecoder(self.fp.read()) # Get info from decoder - self._size, loop_count, bgcolor, frame_count, mode = self._decoder.get_info() - self.info["loop"] = loop_count - bg_a, bg_r, bg_g, bg_b = ( - (bgcolor >> 24) & 0xFF, - (bgcolor >> 16) & 0xFF, - (bgcolor >> 8) & 0xFF, - bgcolor & 0xFF, + self._size, self.info["loop"], bgcolor, self.n_frames, self.rawmode = ( + self._decoder.get_info() + ) + self.info["background"] = ( + (bgcolor >> 16) & 0xFF, # R + (bgcolor >> 8) & 0xFF, # G + bgcolor & 0xFF, # B + (bgcolor >> 24) & 0xFF, # A ) - self.info["background"] = (bg_r, bg_g, bg_b, bg_a) - self.n_frames = frame_count self.is_animated = self.n_frames > 1 - self._mode = "RGB" if mode == "RGBX" else mode - self.rawmode = mode + self._mode = "RGB" if self.rawmode == "RGBX" else self.rawmode # Attempt to read ICC / EXIF / XMP chunks from file - icc_profile = self._decoder.get_chunk("ICCP") - exif = self._decoder.get_chunk("EXIF") - xmp = self._decoder.get_chunk("XMP ") - if icc_profile: - self.info["icc_profile"] = icc_profile - if exif: - self.info["exif"] = exif - if xmp: - self.info["xmp"] = xmp + for key, chunk_name in { + "icc_profile": "ICCP", + "exif": "EXIF", + "xmp": "XMP ", + }.items(): + if value := self._decoder.get_chunk(chunk_name): + self.info[key] = value # Initialize seek state self._reset(reset=False) @@ -129,9 +126,7 @@ class WebPImageFile(ImageFile.ImageFile): self._seek(self.__logical_frame) # We need to load the image data for this frame - data, timestamp, duration = self._get_next() - self.info["timestamp"] = timestamp - self.info["duration"] = duration + data, self.info["timestamp"], self.info["duration"] = self._get_next() self.__loaded = self.__logical_frame # Set tile diff --git a/src/PIL/WmfImagePlugin.py b/src/PIL/WmfImagePlugin.py index de714d337..3ae86242a 100644 --- a/src/PIL/WmfImagePlugin.py +++ b/src/PIL/WmfImagePlugin.py @@ -49,6 +49,7 @@ if hasattr(Image.core, "drawwmf"): self.bbox = im.info["wmf_bbox"] def load(self, im: ImageFile.StubImageFile) -> Image.Image: + assert im.fp is not None im.fp.seek(0) # rewind return Image.frombytes( "RGB", @@ -81,6 +82,7 @@ class WmfStubImageFile(ImageFile.StubImageFile): def _open(self) -> None: # check placeable header + assert self.fp is not None s = self.fp.read(44) if s.startswith(b"\xd7\xcd\xc6\x9a\x00\x00"): diff --git a/src/PIL/_deprecate.py b/src/PIL/_deprecate.py index 616a9aace..711c62ab2 100644 --- a/src/PIL/_deprecate.py +++ b/src/PIL/_deprecate.py @@ -48,6 +48,8 @@ def deprecate( raise RuntimeError(msg) elif when == 13: removed = "Pillow 13 (2026-10-15)" + elif when == 14: + removed = "Pillow 14 (2027-10-15)" else: msg = f"Unknown removal version: {when}. Update {__name__}?" raise ValueError(msg) diff --git a/src/PIL/_version.py b/src/PIL/_version.py index 41cb17a36..96363e9f1 100644 --- a/src/PIL/_version.py +++ b/src/PIL/_version.py @@ -1,4 +1,4 @@ # Master version for Pillow from __future__ import annotations -__version__ = "12.1.0.dev0" +__version__ = "12.2.0.dev0" diff --git a/src/_imaging.c b/src/_imaging.c index f6be4a901..d2a195887 100644 --- a/src/_imaging.c +++ b/src/_imaging.c @@ -2459,7 +2459,6 @@ _merge(PyObject *self, PyObject *args) { static PyObject * _split(ImagingObject *self) { - int fails = 0; Py_ssize_t i; PyObject *list; PyObject *imaging_object; @@ -2473,14 +2472,12 @@ _split(ImagingObject *self) { for (i = 0; i < self->image->bands; i++) { imaging_object = PyImagingNew(bands[i]); if (!imaging_object) { - fails += 1; + Py_DECREF(list); + list = NULL; + break; } PyTuple_SET_ITEM(list, i, imaging_object); } - if (fails) { - Py_DECREF(list); - list = NULL; - } return list; } diff --git a/src/_imagingft.c b/src/_imagingft.c index de7851c1a..da1ed6470 100644 --- a/src/_imagingft.c +++ b/src/_imagingft.c @@ -1325,7 +1325,6 @@ font_getvarnames(FontObject *self) { } PyList_SetItem(list_names, j, list_name); list_names_filled[j] = 1; - break; } } } diff --git a/src/libImaging/BcnDecode.c b/src/libImaging/BcnDecode.c index ac81ed6df..d99b0e28e 100644 --- a/src/libImaging/BcnDecode.c +++ b/src/libImaging/BcnDecode.c @@ -663,7 +663,7 @@ half_to_float(UINT16 h) { if (o.f >= m.f) { o.u |= 255 << 23; } - o.u |= (h & 0x8000) << 16; + o.u |= (UINT32)(h & 0x8000) << 16; return o.f; } diff --git a/src/libImaging/FliDecode.c b/src/libImaging/FliDecode.c index 44994823e..9b494dfa2 100644 --- a/src/libImaging/FliDecode.c +++ b/src/libImaging/FliDecode.c @@ -18,9 +18,9 @@ #define I16(ptr) ((ptr)[0] + ((int)(ptr)[1] << 8)) -#define I32(ptr) \ - ((ptr)[0] + ((INT32)(ptr)[1] << 8) + ((INT32)(ptr)[2] << 16) + \ - ((INT32)(ptr)[3] << 24)) +#define I32(ptr) \ + ((ptr)[0] + ((unsigned long)(ptr)[1] << 8) + ((unsigned long)(ptr)[2] << 16) + \ + ((unsigned long)(ptr)[3] << 24)) #define ERR_IF_DATA_OOB(offset) \ if ((data + (offset)) > ptr + bytes) { \ @@ -31,8 +31,8 @@ int ImagingFliDecode(Imaging im, ImagingCodecState state, UINT8 *buf, Py_ssize_t bytes) { UINT8 *ptr; - int framesize; - int c, chunks, advance; + unsigned long framesize, advance; + int c, chunks; int l, lines; int i, j, x = 0, y, ymax; diff --git a/tox.ini b/tox.ini index 7f116c6e7..de18946ef 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ requires = tox>=4.2 env_list = lint + mypy py{py3, 315, 314, 313, 312, 311, 310} [testenv] @@ -18,11 +19,11 @@ commands = skip_install = true deps = check-manifest - pre-commit + prek pass_env = - PRE_COMMIT_COLOR + PREK_COLOR commands = - pre-commit run --all-files --show-diff-on-failure + prek run --all-files --show-diff-on-failure check-manifest [testenv:mypy] diff --git a/winbuild/README.md b/winbuild/README.md index db71f094e..b1c9262c2 100644 --- a/winbuild/README.md +++ b/winbuild/README.md @@ -11,7 +11,8 @@ For more extensive info, see the [Windows build instructions](build.rst). * Requires Microsoft Visual Studio 2017 or newer with C++ component. * Requires NASM for libjpeg-turbo, a required dependency when using this script. * Requires CMake 3.15 or newer (available as Visual Studio component). -* Tested on Windows Server 2022 with Visual Studio 2022 Enterprise (GitHub Actions). +* Tested on Windows Server 2025 and 2022 with Visual Studio 2022 Enterprise (GitHub + Actions). Here's an example script to build on Windows: diff --git a/winbuild/build_prepare.py b/winbuild/build_prepare.py index c42a1fcf5..3377d952c 100644 --- a/winbuild/build_prepare.py +++ b/winbuild/build_prepare.py @@ -116,8 +116,8 @@ V = { "BROTLI": "1.2.0", "FREETYPE": "2.14.1", "FRIBIDI": "1.0.16", - "HARFBUZZ": "12.2.0", - "JPEGTURBO": "3.1.2", + "HARFBUZZ": "12.3.0", + "JPEGTURBO": "3.1.3", "LCMS2": "2.17", "LIBAVIF": "1.3.0", "LIBIMAGEQUANT": "4.4.1", @@ -125,7 +125,7 @@ V = { "LIBWEBP": "1.6.0", "OPENJPEG": "2.5.4", "TIFF": "4.7.1", - "XZ": "5.8.1", + "XZ": "5.8.2", "ZLIBNG": "2.3.2", } V["LIBPNG_XY"] = "".join(V["LIBPNG"].split(".")[:2]) @@ -183,11 +183,7 @@ DEPS: dict[str, dict[str, Any]] = { "filename": f"xz-{V['XZ']}.tar.gz", "license": "COPYING", "build": [ - *cmds_cmake( - "liblzma", - "-DBUILD_SHARED_LIBS:BOOL=OFF" - + (" -DXZ_CLMUL_CRC:BOOL=OFF" if struct.calcsize("P") == 4 else ""), - ), + *cmds_cmake("liblzma", "-DBUILD_SHARED_LIBS:BOOL=OFF"), cmd_mkdir(r"{inc_dir}\lzma"), cmd_copy(r"src\liblzma\api\lzma\*.h", r"{inc_dir}\lzma"), ],