Skip to content

Commit ac6c167

Browse files
authored
Run tests in parallel with pytest-xdist (#9945)
2 parents d1d4735 + 182d832 commit ac6c167

10 files changed

Lines changed: 34 additions & 30 deletions

File tree

.ci/build.sh

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,5 @@
22

33
set -e
44

5-
python3 -m coverage erase
65
make clean
76
make install-coverage

.ci/requirements-mypy.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ pytest
1212
types-atheris
1313
types-defusedxml
1414
types-olefile
15+
types-psutil
1516
types-setuptools

.ci/test.cmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
python.exe -c "from PIL import Image"
22
IF ERRORLEVEL 1 EXIT /B
3-
python.exe -bb -m pytest -vv -x -W always --cov PIL --cov Tests --cov-report term --cov-report xml Tests
3+
python.exe -bb -m pytest -vv -x -W always --numprocesses=logical --dist=worksteal --cov PIL --cov Tests --cov-report term --cov-report xml Tests

.ci/test.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ set -e
44

55
python3 -c "from PIL import Image"
66

7-
python3 -bb -m pytest -vv -x -W always --cov PIL --cov Tests --cov-report term --cov-report xml Tests $REVERSE
7+
python3 -bb -m pytest -vv -x -W always --numprocesses=logical --dist=worksteal --cov PIL --cov Tests --cov-report term --cov-report xml Tests

.github/workflows/test-mingw.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ jobs:
6565
mingw-w64-x86_64-python-numpy \
6666
mingw-w64-x86_64-python-olefile \
6767
mingw-w64-x86_64-python-pip \
68+
mingw-w64-x86_64-python-psutil \
6869
mingw-w64-x86_64-python-pytest \
6970
mingw-w64-x86_64-python-pytest-cov \
7071
mingw-w64-x86_64-python-pytest-timeout \
@@ -73,7 +74,7 @@ jobs:
7374
pushd depends && ./install_extra_test_images.sh && popd
7475
7576
- name: Build Pillow
76-
run: CFLAGS="-coverage" python3 -m pip install .
77+
run: CFLAGS="-coverage" python3 -m pip install .[tests]
7778

7879
- name: Test Pillow
7980
run: |

.github/workflows/test.yml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ jobs:
4646
"3.11",
4747
]
4848
include:
49-
- { python-version: "3.13", PYTHONOPTIMIZE: 1, REVERSE: "--reverse" }
49+
- { python-version: "3.13", PYTHONOPTIMIZE: 1 }
5050
- { python-version: "3.12", PYTHONOPTIMIZE: 2 }
5151
# Intel
5252
- { os: "macos-26-intel", python-version: "3.11" }
@@ -125,9 +125,6 @@ jobs:
125125
126126
- name: Test
127127
run: |
128-
if [ $REVERSE ]; then
129-
python3 -m pip install pytest-reverse
130-
fi
131128
if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then
132129
xvfb-run -s '-screen 0 1024x768x24' sway&
133130
export WAYLAND_DISPLAY=wayland-1
@@ -137,7 +134,6 @@ jobs:
137134
fi
138135
env:
139136
PYTHONOPTIMIZE: ${{ matrix.PYTHONOPTIMIZE }}
140-
REVERSE: ${{ matrix.REVERSE }}
141137

142138
- name: Prepare to upload errors
143139
if: failure()

Makefile

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ install:
6262

6363
.PHONY: install-coverage
6464
install-coverage:
65-
CFLAGS="-coverage -Werror=implicit-function-declaration" python3 -m pip -v install .
65+
CFLAGS="-coverage -Werror=implicit-function-declaration" python3 -m pip -v install .[tests]
6666
python3 selftest.py
6767

6868
.PHONY: debug
@@ -98,8 +98,7 @@ test:
9898
.PHONY: test-p
9999
test-p:
100100
python3 -c "import xdist" > /dev/null 2>&1 || python3 -m pip install pytest-xdist
101-
python3 -m pytest -qq -n auto
102-
101+
python3 -m pytest -qq --numprocesses=logical --dist=worksteal
103102

104103
.PHONY: valgrind
105104
valgrind:

Tests/helper.py

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,15 @@
2222
if TYPE_CHECKING:
2323
from collections.abc import Callable, Sequence
2424
from pathlib import Path
25+
from types import ModuleType
2526
from typing import Any
2627

28+
psutil: ModuleType | None
29+
try:
30+
import psutil
31+
except ImportError:
32+
psutil = None
33+
2734
logger = logging.getLogger(__name__)
2835

2936
uploader = None
@@ -211,33 +218,33 @@ def is_pypy() -> bool:
211218
return sys.implementation.name == "pypy"
212219

213220

214-
@pytest.mark.skipif(sys.platform.startswith("win32"), reason="Requires Unix or macOS")
221+
@pytest.mark.skipif(psutil is None, reason="psutil not installed")
222+
@pytest.mark.skipif(
223+
sys.platform.startswith("win32"),
224+
reason="Leak limits are not calibrated for Windows",
225+
)
215226
# Per https://stackoverflow.com/a/29007723/51685, due to JIT compilation,
216227
# RSS utilization is known to grow in PyPy.
217228
@pytest.mark.skipif(is_pypy(), reason="max RSS utilization is not stable on PyPy")
218229
class PillowLeakTestCase:
219-
# requires unix/macOS
220230
iterations = 100 # count
221231
mem_limit = 512 # k
222232

223233
def _get_mem_usage(self) -> float:
224234
"""
225-
Gets the RUSAGE memory usage, returns in K. Encapsulates the difference
226-
between macOS and Linux rss reporting
235+
Gets the resident set size currently used by this process.
227236
228237
:returns: memory usage in kilobytes
229238
"""
230239

231-
from resource import RUSAGE_SELF, getrusage
232-
233-
mem = getrusage(RUSAGE_SELF).ru_maxrss
234-
# man 2 getrusage:
235-
# ru_maxrss
236-
# This is the maximum resident set size utilized
237-
# in bytes on macOS, in kilobytes on Linux
238-
return mem / 1024 if sys.platform == "darwin" else mem
240+
assert psutil is not None
241+
return psutil.Process().memory_info().rss / 1024
239242

240243
def _test_leak(self, core: Callable[[], None]) -> None:
244+
# Warm up so allocator arenas, caches, etc. are allocated,
245+
# before taking the baseline measurement.
246+
core()
247+
241248
start_mem = self._get_mem_usage()
242249
for cycle in range(self.iterations):
243250
core()

Tests/test_font_leaks.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,8 @@ def _test_font(self, font: ImageFont.FreeTypeFont | ImageFont.ImageFont) -> None
2323

2424

2525
class TestTTypeFontLeak(TestFontLeak):
26-
# fails at iteration 3 in main
27-
iterations = 10
28-
mem_limit = 4096 # k
26+
iterations = 30
27+
mem_limit = 32768 # k
2928

3029
@skip_unless_feature("freetype2")
3130
def test_leak(self) -> None:
@@ -34,9 +33,8 @@ def test_leak(self) -> None:
3433

3534

3635
class TestDefaultFontLeak(TestFontLeak):
37-
# fails at iteration 37 in main
38-
iterations = 100
39-
mem_limit = 1024 # k
36+
iterations = 1000
37+
mem_limit = 16384 # k
4038

4139
def test_leak(self, monkeypatch: pytest.MonkeyPatch) -> None:
4240
if features.check_module("freetype2"):

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ optional-dependencies.tests = [
6868
"markdown2",
6969
"olefile",
7070
"packaging",
71+
# Only used by the leak tests, which only run on Linux and macOS.
72+
# psutil does not support iOS at all, and ships no wheels for Android.
73+
"psutil; sys_platform=='linux' or sys_platform=='darwin'",
7174
"pytest",
7275
"pytest-cov",
7376
"pytest-timeout",

0 commit comments

Comments
 (0)