diff --git a/.github/compare-dist-sizes.py b/.github/compare-dist-sizes.py index ed7b9be0ed6..0b4f71793d2 100644 --- a/.github/compare-dist-sizes.py +++ b/.github/compare-dist-sizes.py @@ -10,7 +10,7 @@ """ # /// script -# requires-python = ">=3.10" +# requires-python = ">=3.11" # dependencies = [ # "humanize", # "prettytable", diff --git a/.github/generate-sbom.py b/.github/generate-sbom.py index e0324d1429e..6fecfc29ae4 100755 --- a/.github/generate-sbom.py +++ b/.github/generate-sbom.py @@ -59,7 +59,7 @@ def upstream_diff_b64( def generate(version: str) -> dict: serial = str(uuid.uuid4()) - now = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + now = dt.datetime.now(dt.UTC).strftime("%Y-%m-%dT%H:%M:%SZ") purl = f"pkg:pypi/pillow@{version}" root = Path(__file__).parent.parent thirdparty = root / "src" / "thirdparty" diff --git a/.github/workflows/test-docker.yml b/.github/workflows/test-docker.yml index 96ec3e392e6..e11d08fc6ef 100644 --- a/.github/workflows/test-docker.yml +++ b/.github/workflows/test-docker.yml @@ -47,7 +47,6 @@ jobs: fedora-43-amd64, fedora-44-amd64, gentoo, - ubuntu-22.04-jammy-amd64, ubuntu-24.04-noble-amd64, ubuntu-26.04-resolute-amd64, ] diff --git a/.github/workflows/test-valgrind-memory.yml b/.github/workflows/test-valgrind-memory.yml index 3a4957651b1..7d9b4537da6 100644 --- a/.github/workflows/test-valgrind-memory.yml +++ b/.github/workflows/test-valgrind-memory.yml @@ -38,11 +38,12 @@ jobs: fail-fast: false matrix: docker: [ - ubuntu-22.04-jammy-amd64-valgrind, + ubuntu-24.04-noble-amd64-valgrind, ] dockerTag: [main] + split: [1, 2, 3, 4] - name: ${{ matrix.docker }} + name: ${{ matrix.docker }} ${{ matrix.split }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -60,5 +61,5 @@ jobs: run: | # The Pillow user in the docker container is UID 1001 sudo chown -R 1001 $GITHUB_WORKSPACE - docker run --name pillow_container -e "PILLOW_VALGRIND_TEST=true" -v $GITHUB_WORKSPACE:/Pillow pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }} /Pillow/depends/docker-test-valgrind-memory.sh + docker run --name pillow_container -e "PILLOW_VALGRIND_TEST=true" -e "SPLIT_COUNT=4" -e "SPLIT_INDEX=${{ matrix.split }}" -v $GITHUB_WORKSPACE:/Pillow pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }} /Pillow/depends/docker-test-valgrind-memory.sh sudo chown -R runner $GITHUB_WORKSPACE diff --git a/.github/workflows/test-valgrind.yml b/.github/workflows/test-valgrind.yml index ecc00666b96..3c6d0e19eeb 100644 --- a/.github/workflows/test-valgrind.yml +++ b/.github/workflows/test-valgrind.yml @@ -32,11 +32,12 @@ jobs: fail-fast: false matrix: docker: [ - ubuntu-22.04-jammy-amd64-valgrind, + ubuntu-24.04-noble-amd64-valgrind, ] dockerTag: [main] + split: [1, 2, 3, 4] - name: ${{ matrix.docker }} + name: ${{ matrix.docker }} ${{ matrix.split }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 @@ -54,5 +55,5 @@ jobs: run: | # The Pillow user in the docker container is UID 1001 sudo chown -R 1001 $GITHUB_WORKSPACE - docker run --name pillow_container -e "PILLOW_VALGRIND_TEST=true" -v $GITHUB_WORKSPACE:/Pillow pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }} + docker run --name pillow_container -e "PILLOW_VALGRIND_TEST=true" -e "SPLIT_COUNT=4" -e "SPLIT_INDEX=${{ matrix.split }}" -v $GITHUB_WORKSPACE:/Pillow pythonpillow/${{ matrix.docker }}:${{ matrix.dockerTag }} sudo chown -R runner $GITHUB_WORKSPACE diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 52163fd781b..ff8377a503b 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -30,12 +30,12 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["pypy3.11", "3.11", "3.12", "3.13", "3.14", "3.15"] + python-version: ["pypy3.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", os: "windows-2022" } + - { python-version: "3.11", architecture: "x86", os: "windows-2022" } timeout-minutes: 45 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 40db871e97f..9cd227808e9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,15 +44,14 @@ jobs: "3.13", "3.12", "3.11", - "3.10", ] include: - - { python-version: "3.12", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" } - - { python-version: "3.11", PYTHONOPTIMIZE: 2 } + - { python-version: "3.13", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" } + - { python-version: "3.12", PYTHONOPTIMIZE: 2 } # Intel - - { os: "macos-26-intel", python-version: "3.10" } + - { os: "macos-26-intel", python-version: "3.11" } exclude: - - { os: "macos-latest", python-version: "3.10" } + - { os: "macos-latest", python-version: "3.11" } runs-on: ${{ matrix.os }} name: ${{ matrix.os }} Python ${{ matrix.python-version }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 3cb477df777..d4f99b5faf6 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -37,7 +37,7 @@ concurrency: cancel-in-progress: true env: - EXPECTED_DISTS: 87 + EXPECTED_DISTS: 78 FORCE_COLOR: 1 jobs: @@ -81,7 +81,7 @@ jobs: platform: macos os: macos-26-intel cibw_arch: x86_64 - build: "cp3{10,11}*" + build: "cp311*" macosx_deployment_target: "10.10" - name: "macOS 10.13 x86_64" platform: macos diff --git a/Makefile b/Makefile index 0ab4651689c..ba8ccd095f4 100644 --- a/Makefile +++ b/Makefile @@ -114,7 +114,7 @@ valgrind-leak: PILLOW_VALGRIND_TEST=true PYTHONMALLOC=malloc valgrind --suppressions=Tests/oss-fuzz/python.supp \ --leak-check=full --show-leak-kinds=definite --errors-for-leak-kinds=definite \ --log-file=/tmp/valgrind-output \ - python3 -m pytest -vv --valgrind --valgrind-log=/tmp/valgrind-output + python3 -m pytest -vv --valgrind --valgrind-log=/tmp/valgrind-output $(PYTEST_ARGS) .PHONY: readme readme: diff --git a/Tests/test_bmp_reference.py b/Tests/test_bmp_reference.py index ea0853100cc..d0a3c4ed5d8 100644 --- a/Tests/test_bmp_reference.py +++ b/Tests/test_bmp_reference.py @@ -21,9 +21,7 @@ def test_bad() -> None: either""" for f in get_files("b"): # Assert that there is no unclosed file warning - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): try: with Image.open(f) as im: im.load() @@ -56,7 +54,7 @@ def test_questionable() -> None: im.load() if os.path.basename(f) not in supported: print(f"Please add {f} to the partially supported bmp specs.") - except Exception: # noqa: PERF203 + except Exception: if os.path.basename(f) in supported: raise @@ -106,7 +104,7 @@ def get_compare(f: str) -> str: assert_image_similar(im_converted, compare_converted, 5) - except Exception as msg: # noqa: PERF203 + except Exception as msg: # there are three here that are unsupported: unsupported = ( os.path.join(base, "g", "rgb32bf.bmp"), diff --git a/Tests/test_file_avif.py b/Tests/test_file_avif.py index f8b29cb2c93..d667c416919 100644 --- a/Tests/test_file_avif.py +++ b/Tests/test_file_avif.py @@ -208,9 +208,7 @@ def finish(self) -> None: def test_no_resource_warning(self, tmp_path: Path) -> None: with Image.open(TEST_AVIF_FILE) as im: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): im.save(tmp_path / "temp.avif") @pytest.mark.parametrize("major_brand", [b"avif", b"avis", b"mif1", b"msf1"]) @@ -603,7 +601,7 @@ def test_svt_optimizations(self, tmp_path: Path) -> None: @skip_unless_feature("avif") class TestAvifAnimation: @contextmanager - def star_frames(self) -> Generator[list[Image.Image], None, None]: + def star_frames(self) -> Generator[list[Image.Image]]: with Image.open("Tests/images/avif/star.png") as f: yield [f, f.rotate(90), f.rotate(180), f.rotate(270)] @@ -676,7 +674,7 @@ def check(temp_file: Path) -> None: # Test appending using a generator def imGenerator( ims: list[Image.Image], - ) -> Generator[Image.Image, None, None]: + ) -> Generator[Image.Image]: yield from ims temp_file2 = tmp_path / "temp_generator.avif" diff --git a/Tests/test_file_dcx.py b/Tests/test_file_dcx.py index e9d88dd3964..7836d13d2a4 100644 --- a/Tests/test_file_dcx.py +++ b/Tests/test_file_dcx.py @@ -35,18 +35,14 @@ def open_test_image() -> None: def test_closed_file() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): im = Image.open(TEST_FILE) im.load() im.close() def test_context_manager() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): with Image.open(TEST_FILE) as im: im.load() diff --git a/Tests/test_file_fli.py b/Tests/test_file_fli.py index 13c6a43239b..9000c0b01ba 100644 --- a/Tests/test_file_fli.py +++ b/Tests/test_file_fli.py @@ -73,9 +73,7 @@ def open_test_image() -> None: def test_closed_file() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): im = Image.open(static_test_file) im.load() im.close() @@ -91,9 +89,7 @@ def test_seek_after_close() -> None: def test_context_manager() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): with Image.open(static_test_file) as im: im.load() diff --git a/Tests/test_file_gif.py b/Tests/test_file_gif.py index 7b504233d56..52997e694c1 100644 --- a/Tests/test_file_gif.py +++ b/Tests/test_file_gif.py @@ -43,9 +43,7 @@ def open_test_image() -> None: def test_closed_file() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): im = Image.open(TEST_GIF) im.load() im.close() @@ -66,9 +64,7 @@ def test_seek_after_close() -> None: def test_context_manager() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): with Image.open(TEST_GIF) as im: im.load() @@ -1191,7 +1187,7 @@ def test_append_images(tmp_path: Path) -> None: assert reread.n_frames == 3 # Tests appending using a generator - def im_generator(ims: list[Image.Image]) -> Generator[Image.Image, None, None]: + def im_generator(ims: list[Image.Image]) -> Generator[Image.Image]: yield from ims im.save(out, save_all=True, append_images=im_generator(ims)) diff --git a/Tests/test_file_icns.py b/Tests/test_file_icns.py index b9b81850665..c492a343cb3 100644 --- a/Tests/test_file_icns.py +++ b/Tests/test_file_icns.py @@ -20,9 +20,7 @@ def test_sanity() -> None: # (512x512@2x) being loaded with Image.open(TEST_FILE) as im: # Assert that there is no unclosed file warning - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): im.load() assert im.mode == "RGBA" diff --git a/Tests/test_file_im.py b/Tests/test_file_im.py index 55c6b730599..dfd33078847 100644 --- a/Tests/test_file_im.py +++ b/Tests/test_file_im.py @@ -40,18 +40,14 @@ def open_test_image() -> None: def test_closed_file() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): im = Image.open(TEST_IM) im.load() im.close() def test_context_manager() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): with Image.open(TEST_IM) as im: im.load() diff --git a/Tests/test_file_jpeg.py b/Tests/test_file_jpeg.py index 145916ed87d..e6ab1c915e6 100644 --- a/Tests/test_file_jpeg.py +++ b/Tests/test_file_jpeg.py @@ -880,9 +880,7 @@ def test_exif_x_resolution(self, tmp_path: Path) -> None: assert exif[282] == 180 out = tmp_path / "out.jpg" - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): im.save(out, exif=exif) with Image.open(out) as reloaded: diff --git a/Tests/test_file_jpeg2k.py b/Tests/test_file_jpeg2k.py index 8976aea0924..705dac880e5 100644 --- a/Tests/test_file_jpeg2k.py +++ b/Tests/test_file_jpeg2k.py @@ -33,7 +33,7 @@ @pytest.fixture -def card() -> Generator[ImageFile.ImageFile, None, None]: +def card() -> Generator[ImageFile.ImageFile]: with Image.open("Tests/images/test-card.png") as im: im.load() try: diff --git a/Tests/test_file_mpo.py b/Tests/test_file_mpo.py index 7f35693f504..c682fd661cf 100644 --- a/Tests/test_file_mpo.py +++ b/Tests/test_file_mpo.py @@ -59,9 +59,7 @@ def open_test_image() -> None: def test_closed_file() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): im = Image.open(test_files[0]) im.load() im.close() @@ -76,9 +74,7 @@ def test_seek_after_close() -> None: def test_context_manager() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): with Image.open(test_files[0]) as im: im.load() diff --git a/Tests/test_file_pdf.py b/Tests/test_file_pdf.py index 8bb4ff8db92..3fb8ae62b84 100644 --- a/Tests/test_file_pdf.py +++ b/Tests/test_file_pdf.py @@ -164,7 +164,7 @@ def test_save_all(tmp_path: Path) -> None: assert os.path.getsize(outfile) > 0 # Test appending using a generator - def im_generator(ims: list[Image.Image]) -> Generator[Image.Image, None, None]: + def im_generator(ims: list[Image.Image]) -> Generator[Image.Image]: yield from ims im.save(outfile, save_all=True, append_images=im_generator(ims)) diff --git a/Tests/test_file_png.py b/Tests/test_file_png.py index 9f81f3288a7..f4fe1de013d 100644 --- a/Tests/test_file_png.py +++ b/Tests/test_file_png.py @@ -367,9 +367,7 @@ def test_load_verify(self) -> None: with Image.open(TEST_PNG_FILE) as im: # Assert that there is no unclosed file warning - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): im.verify() with Image.open(TEST_PNG_FILE) as im: diff --git a/Tests/test_file_psd.py b/Tests/test_file_psd.py index 79cc8bc00a0..a376ba2e887 100644 --- a/Tests/test_file_psd.py +++ b/Tests/test_file_psd.py @@ -41,18 +41,14 @@ def open_test_image() -> None: def test_closed_file() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): im = Image.open(test_file) im.load() im.close() def test_context_manager() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): with Image.open(test_file) as im: im.load() diff --git a/Tests/test_file_spider.py b/Tests/test_file_spider.py index 71fb434ccf4..933f2f8b4e8 100644 --- a/Tests/test_file_spider.py +++ b/Tests/test_file_spider.py @@ -33,18 +33,14 @@ def open_test_image() -> None: def test_closed_file() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): im = Image.open(TEST_FILE) im.load() im.close() def test_context_manager() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): with Image.open(TEST_FILE) as im: im.load() diff --git a/Tests/test_file_tar.py b/Tests/test_file_tar.py index 084d0f2880d..a6a6fa64727 100644 --- a/Tests/test_file_tar.py +++ b/Tests/test_file_tar.py @@ -53,16 +53,12 @@ def test_unclosed_file() -> None: def test_close() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): tar = TarIO.TarIO(TEST_TAR_FILE, "hopper.jpg") tar.close() def test_contextmanager() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): with TarIO.TarIO(TEST_TAR_FILE, "hopper.jpg"): pass diff --git a/Tests/test_file_tiff.py b/Tests/test_file_tiff.py index e442471d1ca..145ab3e8920 100644 --- a/Tests/test_file_tiff.py +++ b/Tests/test_file_tiff.py @@ -65,9 +65,7 @@ def open_test_image() -> None: open_test_image() def test_closed_file(self) -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): im = Image.open("Tests/images/multipage.tiff") im.load() im.close() @@ -83,9 +81,7 @@ def test_seek_after_close(self) -> None: im.seek(1) def test_context_manager(self) -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): with Image.open("Tests/images/multipage.tiff") as im: im.load() @@ -775,7 +771,7 @@ def test_tiff_save_all(self) -> None: assert reread.n_frames == 3 # Test appending using a generator - def im_generator(ims: list[Image.Image]) -> Generator[Image.Image, None, None]: + def im_generator(ims: list[Image.Image]) -> Generator[Image.Image]: yield from ims mp = BytesIO() diff --git a/Tests/test_file_webp.py b/Tests/test_file_webp.py index e419c29f036..7360c1df06f 100644 --- a/Tests/test_file_webp.py +++ b/Tests/test_file_webp.py @@ -190,9 +190,7 @@ def test_WebPAnimDecoder_with_invalid_args(self) -> None: def test_no_resource_warning(self, tmp_path: Path) -> None: file_path = "Tests/images/hopper.webp" with Image.open(file_path) as image: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): image.save(tmp_path / "temp.webp") def test_file_pointer_could_be_reused(self) -> None: diff --git a/Tests/test_file_webp_animated.py b/Tests/test_file_webp_animated.py index 600448fb9e1..7ae93e76b09 100644 --- a/Tests/test_file_webp_animated.py +++ b/Tests/test_file_webp_animated.py @@ -95,7 +95,7 @@ def check(temp_file: Path) -> None: # Tests appending using a generator def im_generator( ims: list[Image.Image], - ) -> Generator[Image.Image, None, None]: + ) -> Generator[Image.Image]: yield from ims temp_file2 = tmp_path / "temp_generator.webp" diff --git a/Tests/test_image.py b/Tests/test_image.py index 300ea81e00e..fa095f4597f 100644 --- a/Tests/test_image.py +++ b/Tests/test_image.py @@ -752,9 +752,7 @@ def test_no_resource_warning_on_save(self, tmp_path: Path) -> None: # Act/Assert with Image.open(test_file) as im: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): im.save(temp_file) def test_no_new_file_on_error(self, tmp_path: Path) -> None: diff --git a/Tests/test_image_fromqimage.py b/Tests/test_image_fromqimage.py index 1149e29649e..4c6f17267af 100644 --- a/Tests/test_image_fromqimage.py +++ b/Tests/test_image_fromqimage.py @@ -6,8 +6,7 @@ from PIL import Image -with warnings.catch_warnings(): - warnings.simplefilter("ignore", category=DeprecationWarning) +with warnings.catch_warnings(action="ignore", category=DeprecationWarning): from PIL import ImageQt from .helper import assert_image_equal, hopper diff --git a/Tests/test_image_resample.py b/Tests/test_image_resample.py index f188be81ecb..61b2842cc66 100644 --- a/Tests/test_image_resample.py +++ b/Tests/test_image_resample.py @@ -372,7 +372,7 @@ def test_dirty_pixels_la(self) -> None: class TestCoreResamplePasses: @contextmanager - def count(self, diff: int) -> Generator[None, None, None]: + def count(self, diff: int) -> Generator[None]: count = Image.core.get_stats()["new_count"] yield assert Image.core.get_stats()["new_count"] - count == diff @@ -485,9 +485,7 @@ def test_wrong_arguments(self, resample: Image.Resampling) -> None: 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]: + def split_range(size: int, tiles: int) -> Generator[tuple[int, int]]: scale = size / tiles for i in range(tiles): yield int(round(scale * i)), int(round(scale * (i + 1))) diff --git a/Tests/test_image_resize.py b/Tests/test_image_resize.py index 3e8979a5b11..b2abe4c9810 100644 --- a/Tests/test_image_resize.py +++ b/Tests/test_image_resize.py @@ -179,7 +179,7 @@ def test_cross_platform(self, tmp_path: Path) -> None: @pytest.fixture -def gradients_image() -> Generator[ImageFile.ImageFile, None, None]: +def gradients_image() -> Generator[ImageFile.ImageFile]: with Image.open("Tests/images/radial_gradients.png") as im: im.load() try: diff --git a/Tests/test_imageops_usm.py b/Tests/test_imageops_usm.py index 920012d8639..14620c98e47 100644 --- a/Tests/test_imageops_usm.py +++ b/Tests/test_imageops_usm.py @@ -8,7 +8,7 @@ @pytest.fixture -def test_images() -> Generator[dict[str, ImageFile.ImageFile], None, None]: +def test_images() -> Generator[dict[str, ImageFile.ImageFile]]: ims = { "im": Image.open("Tests/images/hopper.ppm"), "snakes": Image.open("Tests/images/color_snakes.png"), diff --git a/Tests/test_imageqt.py b/Tests/test_imageqt.py index 2d7ca0ae0fd..552a9df0783 100644 --- a/Tests/test_imageqt.py +++ b/Tests/test_imageqt.py @@ -51,7 +51,5 @@ def test_image(mode: str) -> None: def test_closed_file() -> None: - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): ImageQt.ImageQt("Tests/images/hopper.gif") diff --git a/Tests/test_numpy.py b/Tests/test_numpy.py index 96478a144f3..290c9914d54 100644 --- a/Tests/test_numpy.py +++ b/Tests/test_numpy.py @@ -256,7 +256,5 @@ def test_no_resource_warning_for_numpy_array() -> None: test_file = "Tests/images/hopper.png" with Image.open(test_file) as im: # Act/Assert - with warnings.catch_warnings(): - warnings.simplefilter("error") - + with warnings.catch_warnings(action="error"): array(im) diff --git a/depends/docker-test-valgrind-memory.sh b/depends/docker-test-valgrind-memory.sh index f0d1d851dd1..9fb97300356 100755 --- a/depends/docker-test-valgrind-memory.sh +++ b/depends/docker-test-valgrind-memory.sh @@ -8,4 +8,9 @@ source /vpy3/bin/activate cd /Pillow make clean make install -make valgrind-leak + +SPLIT_ARGS="" +if [ "${SPLIT_COUNT:-1}" -gt 1 ]; then + SPLIT_ARGS="--splits ${SPLIT_COUNT} --group ${SPLIT_INDEX} --durations-path /depends/.test_durations" +fi +make valgrind-leak PYTEST_ARGS="$SPLIT_ARGS" diff --git a/docs/installation/platform-support.rst b/docs/installation/platform-support.rst index 3cbfebb3b40..c15eb64a491 100644 --- a/docs/installation/platform-support.rst +++ b/docs/installation/platform-support.rst @@ -23,7 +23,7 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Arch | 3.14 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| CentOS Stream 9 | 3.10 | x86-64 | +| CentOS Stream 9 | 3.11 | x86-64 | +----------------------------------+----------------------------+---------------------+ | CentOS Stream 10 | 3.12 | x86-64 | +----------------------------------+----------------------------+---------------------+ @@ -35,22 +35,20 @@ These platforms are built and tested for every change. +----------------------------------+----------------------------+---------------------+ | Gentoo | 3.13 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| macOS 15 Sequoia | 3.11, 3.12, 3.13, 3.14, | arm64 | -| | 3.15, PyPy3 | | -+----------------------------------+----------------------------+---------------------+ -| macOS 26 Tahoe | 3.10 | x86-64 | -+----------------------------------+----------------------------+---------------------+ -| Ubuntu Linux 22.04 LTS (Jammy) | 3.10 | x86-64 | +| macOS 26 Tahoe | 3.12, 3.13, 3.14, 3.15, | arm64 | +| | PyPy3 | | +| +----------------------------+---------------------+ +| | 3.11 | x86-64 | +----------------------------------+----------------------------+---------------------+ -| Ubuntu Linux 24.04 LTS (Noble) | 3.10, 3.11, 3.12, 3.13, | x86-64 | +| Ubuntu Linux 24.04 LTS (Noble) | 3.11, 3.12, 3.13, | x86-64 | | | 3.14, 3.15, PyPy3 | | +----------------------------------+----------------------------+---------------------+ | Ubuntu Linux 26.04 LTS (Resolute)| 3.14 | x86-64, arm64v8, | | | | ppc64le, s390x | +----------------------------------+----------------------------+---------------------+ -| Windows Server 2022 | 3.10 | x86 | +| Windows Server 2022 | 3.11 | x86 | +----------------------------------+----------------------------+---------------------+ -| Windows Server 2025 | 3.11, 3.12, 3.13, 3.14, | x86-64 | +| Windows Server 2025 | 3.12, 3.13, 3.14, | x86-64 | | | 3.15, PyPy3 | | | +----------------------------+---------------------+ | | 3.14 (MinGW) | x86-64 | diff --git a/docs/releasenotes/13.0.0.rst b/docs/releasenotes/13.0.0.rst index e7a97e3cab7..45667d9ec07 100644 --- a/docs/releasenotes/13.0.0.rst +++ b/docs/releasenotes/13.0.0.rst @@ -17,6 +17,12 @@ TODO Backwards incompatible changes ============================== +Python 3.10 +^^^^^^^^^^^ + +Pillow has dropped support for Python 3.10, +which reached end-of-life in October 2026. + Saving I mode images as PNG ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pyproject.toml b/pyproject.toml index dab8602d762..eb53829d21f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,11 +20,10 @@ license-files = [ "LICENSE" ] authors = [ { name = "Jeffrey 'Alex' Clark", email = "aclark@aclark.net" }, ] -requires-python = ">=3.10" +requires-python = ">=3.11" classifiers = [ "Development Status :: 6 - Mature", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -169,7 +168,6 @@ lint.ignore = [ "PT011", # pytest-raises-too-broad "PT012", # pytest-raises-with-multiple-statements "PT017", # pytest-assert-in-except - "PYI034", # flake8-pyi: typing.Self added in Python 3.11 ] lint.per-file-ignores."Tests/oss-fuzz/fuzz_font.py" = [ "I002", @@ -190,7 +188,7 @@ max_supported_python = "3.15" [tool.mypy] follow_imports = "silent" -python_version = "3.10" +python_version = "3.11" disallow_any_generics = true disallow_untyped_defs = true warn_redundant_casts = true diff --git a/setup.py b/setup.py index 476b595e696..168d4f237c9 100644 --- a/setup.py +++ b/setup.py @@ -301,7 +301,7 @@ def _pkg_config(name: str) -> tuple[list[str], list[str]] | None: subprocess.check_output(command_cflags).decode("utf8").strip(), )[::2][1:] return libs, cflags - except Exception: # noqa: PERF203 + except Exception: pass return None diff --git a/src/PIL/ContainerIO.py b/src/PIL/ContainerIO.py index ec9e66c714f..31427a7abff 100644 --- a/src/PIL/ContainerIO.py +++ b/src/PIL/ContainerIO.py @@ -19,6 +19,10 @@ from collections.abc import Iterable from typing import IO, AnyStr, NoReturn +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import Self + class ContainerIO(IO[AnyStr]): """ @@ -147,13 +151,13 @@ def writelines(self, lines: Iterable[AnyStr]) -> NoReturn: def truncate(self, size: int | None = None) -> int: raise NotImplementedError() - def __enter__(self) -> ContainerIO[AnyStr]: + def __enter__(self) -> Self: return self def __exit__(self, *args: object) -> None: self.close() - def __iter__(self) -> ContainerIO[AnyStr]: + def __iter__(self) -> Self: return self def __next__(self) -> AnyStr: diff --git a/src/PIL/GimpPaletteFile.py b/src/PIL/GimpPaletteFile.py index 016257d3dd2..5dc83086dd1 100644 --- a/src/PIL/GimpPaletteFile.py +++ b/src/PIL/GimpPaletteFile.py @@ -20,7 +20,7 @@ TYPE_CHECKING = False if TYPE_CHECKING: - from typing import IO + from typing import IO, Self class GimpPaletteFile: @@ -66,7 +66,7 @@ def __init__(self, fp: IO[bytes]) -> None: self._read(fp) @classmethod - def frombytes(cls, data: bytes) -> GimpPaletteFile: + def frombytes(cls, data: bytes) -> Self: self = cls.__new__(cls) self._read(BytesIO(data), False) return self diff --git a/src/PIL/Image.py b/src/PIL/Image.py index 6bb1a6a4a46..de9fe1e398e 100644 --- a/src/PIL/Image.py +++ b/src/PIL/Image.py @@ -67,7 +67,7 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterator, Sequence from types import ModuleType - from typing import Any, Literal + from typing import Any, Literal, Self logger = logging.getLogger(__name__) @@ -488,7 +488,7 @@ def init() -> bool: try: logger.debug("Importing %s", plugin) __import__(f"{__spec__.parent}.{plugin}", globals(), locals(), []) - except ImportError as e: # noqa: PERF203 + except ImportError as e: logger.debug("Image: failed to import %s: %s", plugin, e) if OPEN or SAVE: @@ -691,7 +691,7 @@ def _new(self, im: core.ImagingCore) -> Image: return new # Context manager support - def __enter__(self) -> Image: + def __enter__(self) -> Self: return self def __exit__(self, *args: object) -> None: @@ -3359,8 +3359,8 @@ class SupportsArrowArrayInterface(Protocol): """ def __arrow_c_array__( - self, requested_schema: "PyCapsule" = None # type: ignore[name-defined] # noqa: F821, UP037 - ) -> tuple["PyCapsule", "PyCapsule"]: # type: ignore[name-defined] # noqa: F821, UP037 + self, requested_schema: PyCapsule = None # type: ignore[name-defined] # noqa: F821 + ) -> tuple[PyCapsule, PyCapsule]: # type: ignore[name-defined] # noqa: F821 raise NotImplementedError() diff --git a/src/PIL/ImageFile.py b/src/PIL/ImageFile.py index 2d69225b5c0..eaa1e45812c 100644 --- a/src/PIL/ImageFile.py +++ b/src/PIL/ImageFile.py @@ -41,6 +41,8 @@ TYPE_CHECKING = False if TYPE_CHECKING: + from typing import Self + from ._typing import StrOrBytesPath logger = logging.getLogger(__name__) @@ -173,10 +175,6 @@ def __init__( def _open(self) -> None: pass - # 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: @@ -185,6 +183,7 @@ def _close_fp(self) -> None: if self.fp: self.fp.close() + # Context manager support def __exit__(self, *args: object) -> None: if getattr(self, "_exclusive_fp", False): self._close_fp() @@ -601,7 +600,7 @@ def feed(self, data: bytes) -> None: self.image = im - def __enter__(self) -> Parser: + def __enter__(self) -> Self: return self def __exit__(self, *args: object) -> None: diff --git a/src/PIL/ImageFont.py b/src/PIL/ImageFont.py index 65edf5659fa..224beef695a 100644 --- a/src/PIL/ImageFont.py +++ b/src/PIL/ImageFont.py @@ -35,7 +35,7 @@ from enum import IntEnum from io import BytesIO from types import ModuleType -from typing import IO, Any, BinaryIO, TypedDict, cast +from typing import IO, Any, BinaryIO, NotRequired, TypedDict, cast from . import Image from ._typing import StrOrBytesPath @@ -52,7 +52,7 @@ class Axis(TypedDict): minimum: int | None default: int | None maximum: int | None - name: bytes | None + name: NotRequired[bytes] class Layout(IntEnum): @@ -732,7 +732,7 @@ def get_variation_axes(self) -> list[Axis]: """ axes = self.font.getvaraxes() for axis in axes: - if axis["name"]: + if "name" in axis: axis["name"] = axis["name"].replace(b"\x00", b"") return axes @@ -951,7 +951,7 @@ def load_path(filename: str | bytes) -> ImageFont: for directory in sys.path: try: return load(os.path.join(directory, filename)) - except OSError: # noqa: PERF203 + except OSError: pass msg = f'cannot find font file "{filename}" in sys.path' if os.path.exists(filename): diff --git a/src/PIL/ImageMath.py b/src/PIL/ImageMath.py index f62c6a3f845..b129ebaad9a 100644 --- a/src/PIL/ImageMath.py +++ b/src/PIL/ImageMath.py @@ -24,7 +24,7 @@ if TYPE_CHECKING: from collections.abc import Callable from types import CodeType - from typing import Any + from typing import Any, Self class _Operand: @@ -104,7 +104,7 @@ def __bool__(self) -> bool: def __abs__(self) -> _Operand: return self.apply("abs", self) - def __pos__(self) -> _Operand: + def __pos__(self) -> Self: return self def __neg__(self) -> _Operand: diff --git a/src/PIL/ImageSequence.py b/src/PIL/ImageSequence.py index 361be48971e..9d3553a75a8 100644 --- a/src/PIL/ImageSequence.py +++ b/src/PIL/ImageSequence.py @@ -21,6 +21,7 @@ TYPE_CHECKING = False if TYPE_CHECKING: from collections.abc import Callable + from typing import Self class Iterator: @@ -50,7 +51,7 @@ def __getitem__(self, ix: int) -> Image.Image: msg = "end of sequence" raise IndexError(msg) from e - def __iter__(self) -> Iterator: + def __iter__(self) -> Self: return self def __next__(self) -> Image.Image: diff --git a/src/PIL/PcfFontFile.py b/src/PIL/PcfFontFile.py index 985444106a1..f12506a6666 100644 --- a/src/PIL/PcfFontFile.py +++ b/src/PIL/PcfFontFile.py @@ -249,7 +249,7 @@ def _load_encoding(self) -> list[int | None]: ] if encoding_offset != 0xFFFF: encoding[i] = encoding_offset - except UnicodeDecodeError: # noqa: PERF203 + except UnicodeDecodeError: # character is not supported in selected encoding pass diff --git a/src/PIL/PdfParser.py b/src/PIL/PdfParser.py index c2e1568f969..5ba70ba209d 100644 --- a/src/PIL/PdfParser.py +++ b/src/PIL/PdfParser.py @@ -14,7 +14,7 @@ TYPE_CHECKING = False if TYPE_CHECKING: - from typing import IO + from typing import IO, Self _DictBase = collections.UserDict[str | bytes, Any] else: @@ -427,7 +427,7 @@ def __init__( if f: self.seek_end() - def __enter__(self) -> PdfParser: + def __enter__(self) -> Self: return self def __exit__(self, *args: object) -> None: diff --git a/src/PIL/PngImagePlugin.py b/src/PIL/PngImagePlugin.py index c777b4d3162..77af87861fb 100644 --- a/src/PIL/PngImagePlugin.py +++ b/src/PIL/PngImagePlugin.py @@ -53,7 +53,7 @@ TYPE_CHECKING = False if TYPE_CHECKING: from collections.abc import Callable - from typing import Any, NoReturn + from typing import Any, NoReturn, Self from . import _imaging @@ -184,7 +184,7 @@ def read(self) -> tuple[bytes, int, int]: return cid, pos, length - def __enter__(self) -> ChunkStream: + def __enter__(self) -> Self: return self def __exit__(self, *args: object) -> None: @@ -265,7 +265,7 @@ class iTXt(str): @staticmethod def __new__( cls, text: str, lang: str | None = None, tkey: str | None = None - ) -> iTXt: + ) -> Self: """ :param cls: the class to use when creating the instance :param text: value for this key @@ -399,9 +399,6 @@ def __init__(self, fp: IO[bytes]) -> None: self.text_memory = 0 - def __enter__(self) -> PngStream: - return self - def check_text_memory(self, chunklen: int) -> None: self.text_memory += chunklen if self.text_memory > MAX_TEXT_MEMORY: diff --git a/src/PIL/TiffImagePlugin.py b/src/PIL/TiffImagePlugin.py index 472dfcf5802..c3a1852ab9c 100644 --- a/src/PIL/TiffImagePlugin.py +++ b/src/PIL/TiffImagePlugin.py @@ -62,7 +62,7 @@ TYPE_CHECKING = False if TYPE_CHECKING: from collections.abc import Iterator - from typing import NoReturn + from typing import NoReturn, Self from ._typing import Buffer, IntegralLike, StrOrBytesPath @@ -465,9 +465,7 @@ def __setstate__(self, state: list[float | Fraction | IntegralLike]) -> None: __floor__ = _delegate("__floor__") __round__ = _delegate("__round__") __float__ = _delegate("__float__") - # Python >= 3.11 - if hasattr(Fraction, "__int__"): - __int__ = _delegate("__int__") + __int__ = _delegate("__int__") _LoaderFunc = Callable[["ImageFileDirectory_v2", bytes, bool], Any] @@ -2119,7 +2117,7 @@ def newFrame(self) -> None: self.finalize() self.setup() - def __enter__(self) -> AppendingTiffWriter: + def __enter__(self) -> Self: return self def __exit__(self, *args: object) -> None: diff --git a/src/PIL/TiffTags.py b/src/PIL/TiffTags.py index 613a3b7def7..8ce84a175c0 100644 --- a/src/PIL/TiffTags.py +++ b/src/PIL/TiffTags.py @@ -20,6 +20,10 @@ from typing import NamedTuple +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import Self + class _TagInfo(NamedTuple): value: int | None @@ -39,7 +43,7 @@ def __new__( type: int | None = None, length: int | None = None, enum: dict[str, int] | None = None, - ) -> TagInfo: + ) -> Self: return super().__new__(cls, value, name, type, length, enum or {}) def cvt_enum(self, value: str) -> int | str: diff --git a/tox.ini b/tox.ini index 01912dc6cd1..32ccae6d87d 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ env_list = lint mypy py{py3} - py{310-315} + py{311-315} py{314-315}t [testenv]