From 15e3d829d773aebd29d35c0b5e481c60675a5a43 Mon Sep 17 00:00:00 2001 From: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:43:39 +0300 Subject: [PATCH 1/5] Mark PillowLeakTestCase as isolated --- Tests/helper.py | 1 + pyproject.toml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/Tests/helper.py b/Tests/helper.py index 1ede4b7d5b1..51b06db7d73 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -211,6 +211,7 @@ def is_pypy() -> bool: return sys.implementation.name == "pypy" +@pytest.mark.isolated @pytest.mark.skipif(sys.platform.startswith("win32"), reason="Requires Unix or macOS") # Per https://stackoverflow.com/a/29007723/51685, due to JIT compilation, # RSS utilization is known to grow in PyPy. diff --git a/pyproject.toml b/pyproject.toml index e7adc432c86..eab16a83d14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -209,6 +209,9 @@ num_workers = 4 [tool.pytest] addopts = [ "-ra", "--color=auto" ] +markers = [ + "isolated: tests that are not compatible with pytest-xdist (deselect with '-m \"not isolated\"')", +] testpaths = [ "Tests", ] From 2dc9bd9315c59a9d5b842a3c29b5ef8a71ee51b1 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 2 Sep 2026 17:54:41 +0300 Subject: [PATCH 2/5] CI: Run tests in parallel with pytest-xdist Pass the options at each call site rather than putting them in addopts, so pytests may also run without test-xdist. REVERSE dropped because xdist runs tests in arbitrary order anyhow. That matrix entry still does PYTHONOPTIMIZE=1. Co-authored-by: Hugo van Kemenade <1324225+hugovk@users.noreply.github.com> --- .ci/test.cmd | 4 +++- .ci/test.sh | 5 ++++- .github/workflows/test.yml | 6 +----- Makefile | 6 +++--- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.ci/test.cmd b/.ci/test.cmd index acfac3d1acd..59ac7acb9ff 100644 --- a/.ci/test.cmd +++ b/.ci/test.cmd @@ -1,3 +1,5 @@ python.exe -c "from PIL import Image" IF ERRORLEVEL 1 EXIT /B -python.exe -bb -m pytest -vv -x -W always --cov PIL --cov Tests --cov-report term --cov-report xml Tests +python.exe -bb -m pytest -vv -x -W always Tests -m "isolated" +IF ERRORLEVEL 1 EXIT /B +python.exe -bb -m pytest -vv -x -W always Tests -m "not isolated" --numprocesses=logical --dist=worksteal --cov PIL --cov Tests --cov-report term --cov-report xml diff --git a/.ci/test.sh b/.ci/test.sh index 87a605d84be..e05b3931d56 100755 --- a/.ci/test.sh +++ b/.ci/test.sh @@ -4,4 +4,7 @@ set -e python3 -c "from PIL import Image" -python3 -bb -m pytest -vv -x -W always --cov PIL --cov Tests --cov-report term --cov-report xml Tests $REVERSE +python3 -bb -m pytest -vv -x -W always Tests -m "isolated" +python3 -bb -m pytest -vv -x -W always Tests -m "not isolated" \ + --numprocesses=logical --dist=worksteal \ + --cov PIL --cov Tests --cov-report term --cov-report xml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e7430ef6edc..8765148bb19 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,7 +46,7 @@ jobs: "3.11", ] include: - - { python-version: "3.13", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" } + - { python-version: "3.13", PYTHONOPTIMIZE: 1 } - { python-version: "3.12", PYTHONOPTIMIZE: 2 } # Intel - { os: "macos-26-intel", python-version: "3.11" } @@ -125,9 +125,6 @@ jobs: - name: Test run: | - if [ $REVERSE ]; then - python3 -m pip install pytest-reverse - fi if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then xvfb-run -s '-screen 0 1024x768x24' sway& export WAYLAND_DISPLAY=wayland-1 @@ -137,7 +134,6 @@ jobs: fi env: PYTHONOPTIMIZE: ${{ matrix.PYTHONOPTIMIZE }} - REVERSE: ${{ matrix.REVERSE }} - name: Prepare to upload errors if: failure() diff --git a/Makefile b/Makefile index 3322d754e32..2ef6f99dcfd 100644 --- a/Makefile +++ b/Makefile @@ -97,9 +97,9 @@ test: .PHONY: test-p test-p: - python3 -c "import xdist" > /dev/null 2>&1 || python3 -m pip install pytest-xdist - python3 -m pytest -qq -n auto - + python3 -c "import xdist" > /dev/null 2>&1 || python3 -m pip install pytest-xdist psutil + python3 -m pytest -qq -m "isolated" + python3 -m pytest -qq -m "not isolated" --numprocesses=logical --dist=worksteal .PHONY: valgrind valgrind: From 36b06334974732cbdc5fbc45966f6e09bc8c4af3 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 2 Sep 2026 17:43:37 +0300 Subject: [PATCH 3/5] Measure resident memory in leak tests, not peak memory * ru_maxrss never falls, so leak tests were measuring occasional transient spikes, not true leaks. Instead, for leak tests we now read the current RSS, which falls (to a degree, depending on system allocator strategy) when memory is released, and also doesn't depend on the process's past. * The tests' limits were adjusted based on empirical measurements. --- .ci/requirements-mypy.txt | 1 + Tests/helper.py | 31 +++++++++++++++++++------------ Tests/test_font_leaks.py | 10 ++++------ pyproject.toml | 3 +++ 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/.ci/requirements-mypy.txt b/.ci/requirements-mypy.txt index e2c99e5c602..40e2a83c811 100644 --- a/.ci/requirements-mypy.txt +++ b/.ci/requirements-mypy.txt @@ -12,4 +12,5 @@ pytest types-atheris types-defusedxml types-olefile +types-psutil types-setuptools diff --git a/Tests/helper.py b/Tests/helper.py index 51b06db7d73..d025040230e 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -22,8 +22,15 @@ if TYPE_CHECKING: from collections.abc import Callable, Sequence from pathlib import Path + from types import ModuleType from typing import Any +psutil: ModuleType | None +try: + import psutil +except ImportError: + psutil = None + logger = logging.getLogger(__name__) uploader = None @@ -212,33 +219,33 @@ def is_pypy() -> bool: @pytest.mark.isolated -@pytest.mark.skipif(sys.platform.startswith("win32"), reason="Requires Unix or macOS") +@pytest.mark.skipif(psutil is None, reason="psutil not installed") +@pytest.mark.skipif( + sys.platform.startswith("win32"), + reason="Leak limits are not calibrated for Windows", +) # Per https://stackoverflow.com/a/29007723/51685, due to JIT compilation, # RSS utilization is known to grow in PyPy. @pytest.mark.skipif(is_pypy(), reason="max RSS utilization is not stable on PyPy") class PillowLeakTestCase: - # requires unix/macOS iterations = 100 # count mem_limit = 512 # k def _get_mem_usage(self) -> float: """ - Gets the RUSAGE memory usage, returns in K. Encapsulates the difference - between macOS and Linux rss reporting + Gets the resident set size currently used by this process. :returns: memory usage in kilobytes """ - from resource import RUSAGE_SELF, getrusage - - mem = getrusage(RUSAGE_SELF).ru_maxrss - # man 2 getrusage: - # ru_maxrss - # This is the maximum resident set size utilized - # in bytes on macOS, in kilobytes on Linux - return mem / 1024 if sys.platform == "darwin" else mem + assert psutil is not None + return psutil.Process().memory_info().rss / 1024 def _test_leak(self, core: Callable[[], None]) -> None: + # Warm up so allocator arenas, caches, etc. are allocated, + # before taking the baseline measurement. + core() + start_mem = self._get_mem_usage() for cycle in range(self.iterations): core() diff --git a/Tests/test_font_leaks.py b/Tests/test_font_leaks.py index abff5bc6f28..ff837b8c302 100644 --- a/Tests/test_font_leaks.py +++ b/Tests/test_font_leaks.py @@ -23,9 +23,8 @@ def _test_font(self, font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> None class TestTTypeFontLeak(TestFontLeak): - # fails at iteration 3 in main - iterations = 10 - mem_limit = 4096 # k + iterations = 30 + mem_limit = 32768 # k @skip_unless_feature("freetype2") def test_leak(self) -> None: @@ -34,9 +33,8 @@ def test_leak(self) -> None: class TestDefaultFontLeak(TestFontLeak): - # fails at iteration 37 in main - iterations = 100 - mem_limit = 1024 # k + iterations = 1000 + mem_limit = 16384 # k def test_leak(self, monkeypatch: pytest.MonkeyPatch) -> None: if features.check_module("freetype2"): diff --git a/pyproject.toml b/pyproject.toml index eab16a83d14..d01f86f4dc9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,6 +68,9 @@ optional-dependencies.tests = [ "markdown2", "olefile", "packaging", + # Only used by the leak tests, which only run on Linux and macOS. + # psutil does not support iOS at all, and ships no wheels for Android. + "psutil; sys_platform=='linux' or sys_platform=='darwin'", "pytest", "pytest-cov", "pytest-timeout", From f46990eb3c7498b8e902c7feb970a2f143c3d96a Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 9 Sep 2026 15:55:04 +1000 Subject: [PATCH 4/5] Revert "Mark PillowLeakTestCase as isolated" This reverts commit 15e3d829d773aebd29d35c0b5e481c60675a5a43. --- Tests/helper.py | 1 - pyproject.toml | 3 --- 2 files changed, 4 deletions(-) diff --git a/Tests/helper.py b/Tests/helper.py index d025040230e..7571bc7113b 100644 --- a/Tests/helper.py +++ b/Tests/helper.py @@ -218,7 +218,6 @@ def is_pypy() -> bool: return sys.implementation.name == "pypy" -@pytest.mark.isolated @pytest.mark.skipif(psutil is None, reason="psutil not installed") @pytest.mark.skipif( sys.platform.startswith("win32"), diff --git a/pyproject.toml b/pyproject.toml index d01f86f4dc9..d85a5adb716 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -212,9 +212,6 @@ num_workers = 4 [tool.pytest] addopts = [ "-ra", "--color=auto" ] -markers = [ - "isolated: tests that are not compatible with pytest-xdist (deselect with '-m \"not isolated\"')", -] testpaths = [ "Tests", ] From 2cee23885538d5f0be5c7d1e5967a25eae53d683 Mon Sep 17 00:00:00 2001 From: Andrew Murray Date: Wed, 9 Sep 2026 15:58:12 +1000 Subject: [PATCH 5/5] Do not run isolated and non-isolated tests separately --- .ci/test.cmd | 4 +--- .ci/test.sh | 3 +-- Makefile | 3 +-- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.ci/test.cmd b/.ci/test.cmd index 59ac7acb9ff..2a763bbf9f5 100644 --- a/.ci/test.cmd +++ b/.ci/test.cmd @@ -1,5 +1,3 @@ python.exe -c "from PIL import Image" IF ERRORLEVEL 1 EXIT /B -python.exe -bb -m pytest -vv -x -W always Tests -m "isolated" -IF ERRORLEVEL 1 EXIT /B -python.exe -bb -m pytest -vv -x -W always Tests -m "not isolated" --numprocesses=logical --dist=worksteal --cov PIL --cov Tests --cov-report term --cov-report xml +python.exe -bb -m pytest -vv -x -W always Tests --numprocesses=logical --dist=worksteal --cov PIL --cov Tests --cov-report term --cov-report xml diff --git a/.ci/test.sh b/.ci/test.sh index e05b3931d56..8aa5ef18348 100755 --- a/.ci/test.sh +++ b/.ci/test.sh @@ -4,7 +4,6 @@ set -e python3 -c "from PIL import Image" -python3 -bb -m pytest -vv -x -W always Tests -m "isolated" -python3 -bb -m pytest -vv -x -W always Tests -m "not isolated" \ +python3 -bb -m pytest -vv -x -W always Tests \ --numprocesses=logical --dist=worksteal \ --cov PIL --cov Tests --cov-report term --cov-report xml diff --git a/Makefile b/Makefile index 2ef6f99dcfd..3e5213db0de 100644 --- a/Makefile +++ b/Makefile @@ -98,8 +98,7 @@ test: .PHONY: test-p test-p: python3 -c "import xdist" > /dev/null 2>&1 || python3 -m pip install pytest-xdist psutil - python3 -m pytest -qq -m "isolated" - python3 -m pytest -qq -m "not isolated" --numprocesses=logical --dist=worksteal + python3 -m pytest -qq --numprocesses=logical --dist=worksteal .PHONY: valgrind valgrind: