Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/coverage.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# patch -- "subprocess" (coverage 7.10+) starts measurement in every
# child Python process. pytest-xdist's workers are such children
# (execnet popen), so without it only the controller is measured
# and the -n auto run reports next to nothing. "_exit" saves the
# and the -n logical run reports next to nothing. "_exit" saves the
# data before os._exit(), which skips atexit: execnet calls it on
# a worker whose execution outlives shutdown, and pytest-timeout's
# thread method on a hung test (#1387).
Expand All @@ -26,7 +26,7 @@
# generation's code objects: measured on test_http_unit.py
# alone, re-importing per test, peak RSS 0.40 GiB without
# coverage, 4.17 GiB under sysmon and 0.41 GiB under ctrace.
# Under -n auto that growth killed the runner.
# Under xdist that growth killed the runner.

[tool.coverage.run]
source = [
Expand Down
8 changes: 5 additions & 3 deletions .github/workflows/experimental.yml
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,8 @@ jobs:
timeout-minutes: 120
env:
GRAAL_PYTHON_VM_ARGS: ${{ startsWith(matrix.python-version, 'graalpy') && '--vm.Xmx2g --vm.XX:+ExitOnOutOfMemoryError' || '' }}
WORKERS: ${{ startsWith(matrix.python-version, 'graalpy') && '3' || 'auto' }}
# #2571: 3.14t, which installs psutil, takes `logical` as unit-tests.yml `test` does.
WORKERS: ${{ startsWith(matrix.python-version, 'graalpy') && '3' || 'logical' }}
# #1963: folded, so each line break reads as the space between two shards.
SHARDS: >-
${{ startsWith(matrix.python-version, 'graalpy') && 'tests/const
Expand Down Expand Up @@ -680,7 +681,8 @@ jobs:
run: python -c "import os; print('cpu_count', os.cpu_count())"

# `experimental`'s selection, the `ALL` tier (#1958), asked of
# tests/_tiers.py on the leg's own CPython, as `test` asks it.
# tests/_tiers.py on the leg's own CPython, as `test` asks it; the flags
# are `test`'s.
- name: Run unit tests
id: tests
if: ${{ steps.install.outcome == 'success' }}
Expand All @@ -692,7 +694,7 @@ jobs:
echo "::${ANNOTATION_LEVEL} title=Could not compute the LATEST tier::tests._tiers.latest_tier_paths() failed -- see the traceback above."
exit 1
fi
set -- -q -n auto --dist load \
set -- -q -n logical --dist load \
--durations=30 --durations-min=1.0 --junitxml="$RUNNER_TEMP/junit.xml" \
--ignore=tests/integration \
--ignore-glob='*_runtime.py' \
Expand Down
16 changes: 10 additions & 6 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,8 @@ jobs:
#
# `read -ra` splits the selection on whitespace, as an unquoted
# expansion would, but with no pathname expansion of a node ID.
#
# `-n logical` for the reason in `test`'s `Run unit tests` (#2571).
- name: Run full test suite
id: suite
if: ${{ steps.install.outcome == 'success' }}
Expand All @@ -476,7 +478,7 @@ jobs:
fi
echo "Fixture-dependent selection: $selection"
read -ra selection <<< "$selection"
python util/cap_file_size.py python -m pytest -q -n auto --dist load \
python util/cap_file_size.py python -m pytest -q -n logical --dist load \
--durations=30 --durations-min=1.0 --junitxml="$RUNNER_TEMP/junit.xml" "${selection[@]}"

# See the `test` job's step of the same name.
Expand Down Expand Up @@ -716,6 +718,8 @@ jobs:
# that would otherwise skip it silently (#845 finding 2). `set +e` covers
# only that install; the pip upgrade and the `test` extra run under
# `set -eu`, as a broken base install would otherwise read as "expected" (#845).
#
# `-n logical` for the reason in `test`'s `Run unit tests` (#2571).
- name: Write the per-cell script
run: |
cat > "$RUNNER_TEMP/engine-cell.sh" <<'CELL'
Expand Down Expand Up @@ -803,7 +807,7 @@ jobs:
esac
echo "Running: $TEST_PATHS"
# shellcheck disable=SC2086
if ! python util/cap_file_size.py python -m pytest -q -n auto --dist load $TEST_PATHS; then
if ! python util/cap_file_size.py python -m pytest -q -n logical --dist load $TEST_PATHS; then
echo "::${ANNOTATION_LEVEL} title=$ENGINE tests failed on Python $PY::See the 'Python $PY ($ENGINE)' group in this step's log."
exit 1
fi
Expand Down Expand Up @@ -1060,7 +1064,7 @@ jobs:
fi
echo "Fixture-dependent selection: $selection"
read -ra selection <<< "$selection"
python util/cap_file_size.py python -m pytest -q -n auto --dist load "${selection[@]}"
python util/cap_file_size.py python -m pytest -q -n logical --dist load "${selection[@]}"

# #1672: see the `test` job's step of the same name.
- name: KTLO summary
Expand Down Expand Up @@ -1371,7 +1375,7 @@ jobs:
exit 1
fi
read -ra pins <<< "$pins"
python util/cap_file_size.py python -m pytest -q -n auto --dist load \
python util/cap_file_size.py python -m pytest -q -n logical --dist load \
--durations=30 --durations-min=1.0 --junitxml="$RUNNER_TEMP/junit.xml" "${pins[@]}"

# #2164: the script's default stall dump sits under `unittest-ordering`'s
Expand Down Expand Up @@ -1443,9 +1447,9 @@ jobs:
# Deliberately the whole suite, unlike `integration` (#715): `gate` runs
# only under `gate-only: true`, never per PR push, and partitioning it
# would trade away the "trust nothing, re-run everything" property this
# job exists for. `--dist load` as in the `test` job.
# job exists for. `-n logical --dist load` as in the `test` job.
- name: Run full test suite
run: python util/cap_file_size.py python -m pytest -q -n auto --dist load
run: python util/cap_file_size.py python -m pytest -q -n logical --dist load

# Ruleset 23497679's required_status_checks requires these contexts: this
# job's `Required checks passed`, a `Compat Python` one per supported version,
Expand Down
2 changes: 1 addition & 1 deletion docs/source/contributing/workflows.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1189,7 +1189,7 @@ warnings:

* GraalPy alone gets ``GRAAL_PYTHON_VM_ARGS=--vm.Xmx2g --vm.XX:+ExitOnOutOfMemoryError``,
a 2 GiB heap for every GraalPy process, xdist workers included, and ``-n 3``, a CPU
of the runner's 4 left to the JIT and the runner; 3.14t keeps ``-n auto``. A
of the runner's 4 left to the JIT and the runner; 3.14t runs ``-n logical``. A
worker's live heap grows with the tests it runs (`#1753
<https://github.com/JarryShaw/PyPCAPKit/issues/1753>`__): in job 116172356218 all
three reached 2.2-2.6 GB RSS in 16 minutes, and a ``MemoryError`` in pytest's own
Expand Down
6 changes: 3 additions & 3 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,7 @@ def _tier_guard_lock_path() -> 'pathlib.Path':
"""Where the probe/scan mutex lives, one file per checkout.

Keyed by :data:`tests._tiers.ROOT` rather than a fixed name, so two
worktrees of this repository each running their own ``-n auto`` session on
worktrees of this repository each running their own xdist session on
the same host never share a lock file neither of them needs shared.

"""
Expand Down Expand Up @@ -600,7 +600,7 @@ def _tier_guard_lock(exclusive: 'bool') -> 'Iterator[None]':
:class:`~tests.test_tier_guard_xdist.XdistSubprocessTests` deliberately
writes a real violation to disk, under :data:`~tests._tiers.PROBE_MODULE_NAME`, to
reproduce the tier guard's own collection-time diagnostic end to end.
Serially the two never overlap; under the ``-n auto --dist load`` this
Serially the two never overlap; under the ``--dist load`` this
suite's CI jobs use, pytest-xdist may hand the two tests to different
worker processes at the same moment, and the scan can then observe the
probe mid-life and report a violation nobody committed -- a real defect in
Expand All @@ -619,7 +619,7 @@ def _tier_guard_lock(exclusive: 'bool') -> 'Iterator[None]':

A no-op wherever :data:`fcntl` is :data:`None` -- true only off POSIX, such
as the Windows leg of :file:`.github/workflows/experimental.yml`, which runs
``-n auto`` too. Nothing that has to stay correct there relies on the lock
``--dist load`` too. Nothing that has to stay correct there relies on the lock
(#2114): :func:`tests._tiers.suite_modules` never lists the probe, and
:class:`~tests.test_tier_guard.SuiteIsCleanTests` skips it where this is a
no-op. :func:`msvcrt.locking` is no substitute, since it has no shared mode.
Expand Down
2 changes: 1 addition & 1 deletion tests/project/test_coverage_rcfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def test_every_pyproject_setting_is_repeated_unchanged(self) -> None:
self.assertEqual(self.rcfile.get(section, {}).get(key), value)

def test_the_xdist_workers_are_measured(self) -> None:
"""Without these two keys an ``-n auto`` run measures only the controller."""
"""Without these two keys an ``-n logical`` run measures only the controller."""
run = self.rcfile['run']
self.assertIs(run.get('parallel'), True)
self.assertIn('subprocess', run.get('patch', ()))
Expand Down
4 changes: 2 additions & 2 deletions tests/project/test_project_pins_once_1954.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def test_it_runs_on_latest_with_what_test_installs(self) -> None:
def test_it_runs_under_the_caps(self) -> None:
"""xdist as ``test`` runs it, a 2 GiB file cap, and a step cap under the job's."""
run = str(step(JOB, STEP)['run'])
self.assertIn('python util/cap_file_size.py python -m pytest -q -n auto --dist load', run)
self.assertIn('python util/cap_file_size.py python -m pytest -q -n logical --dist load', run)
self.assertIn(_tiers.PROJECT_PINS_SELECTOR, run)
self.assertLess(step(JOB, STEP)['timeout-minutes'], job(*JOB)['timeout-minutes'])

Expand All @@ -75,7 +75,7 @@ class TestEveryPinStillRunsWhereTheMatrixDoesNot(unittest.TestCase):

def test_the_gate_runs_the_whole_suite(self) -> None:
self.assertEqual(step(('unit-tests.yml', 'gate'), 'Run full test suite')['run'],
'python util/cap_file_size.py python -m pytest -q -n auto --dist load')
'python util/cap_file_size.py python -m pytest -q -n logical --dist load')

def test_the_ordering_cell_runs_every_pin(self) -> None:
"""#2254: the ``project`` cells between them, as they cut ``tests/project`` in two."""
Expand Down
5 changes: 3 additions & 2 deletions tests/project/test_resource_monitor_1740.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,10 +285,11 @@ def test_graalpy_alone_gets_a_2g_heap_and_3_workers(self) -> None:
graalpy = version.startswith('graalpy')
with self.subTest(version=version):
self.assertEqual(by_impl(env['GRAAL_PYTHON_VM_ARGS'], version), HEAP if graalpy else '')
self.assertEqual(by_impl(env['WORKERS'], version), GRAAL_WORKERS if graalpy else 'auto')
# #2571: 3.14t installs psutil, under which `auto` counts physical cores.
self.assertEqual(by_impl(env['WORKERS'], version), GRAAL_WORKERS if graalpy else 'logical')
script = step(TESTS)['run']
self.assertIn('set -- -q -n "$WORKERS" --dist load', script)
self.assertNotIn('-n auto', script)
self.assertEqual(re.findall(r'-q -n (\S+)', script), ['"$WORKERS"'])

def test_the_timeout_ends_the_tests_before_the_step_cap(self) -> None:
script = step(TESTS)['run']
Expand Down
116 changes: 108 additions & 8 deletions tests/project/test_sweeps_on_latest_only_1732.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
gives either job, with a ``python`` on ``PATH`` that records its arguments, so what
is checked is the selection bash builds, not what the text appears to say.
:class:`TestTheIgnoresAreTheLatestTier` asks pytest itself what those ignores leave
out.
out. :class:`TestEveryXdistRunCountsLogicalCPUs` holds every xdist run in the
workflows but GraalPy's to ``-n logical`` (#2573).

The owner's second ruling on #1732, option 2: nothing waits on the experimental
legs, which with the caps GraalPy needs run for up to two hours. A job of
Expand All @@ -32,6 +33,7 @@

from __future__ import annotations

import copy
import os
import pathlib
import re
Expand All @@ -46,11 +48,11 @@
from tests._support import bash_argv
from tests.project.test_graalpy_shards_1963 import rest_ignores
from tests.project.test_python_matrix_tiers_1633 import REQUIRED, is_experimental, job, legs, workflow
from tests.project.test_python_versions_1660 import ROOT, live_outputs, prose
from tests.project.test_python_versions_1660 import ROOT, live_outputs, prose, workflows
from tests.project.test_release_gates import NULL, evaluate_expression

if TYPE_CHECKING:
from typing import Any, Optional
from typing import Any, Iterator, Optional

#: Test tier (#1958): checks the workflows' tier selections.
__tier__ = 'LATEST'
Expand All @@ -71,6 +73,12 @@
#: main's, ``experimental``'s are sized for GraalPy (88-113 minutes, estimate).
#: The watcher's interpreter and its requests are capped too (#1938).
CAPS = {JOB: (45, {}), EXPERIMENTAL: (140, {'python': 5, 'install': 10, 'watcher-python': 1, 'watcher': 1, 'tests': 120})}
#: A pytest-xdist worker count on a command line: ``-n X``, ``-nX``, ``--numprocesses X`` or
#: ``--numprocesses=X``, with or without ``--dist`` after it.
WORKERS_FLAG = re.compile(r'(?<![\w-])(?:--numprocesses(?:=|\s+)|-n\s*)(?P<value>[^\s;|&]+)')
#: What ``-n auto`` and ``-n logical`` resolve to wherever it is set, as pytest-xdist's
#: ``pytest_xdist_auto_num_workers`` reads it first.
XDIST_ENV = 'PYTEST_XDIST_AUTO_NUM_WORKERS'
#: The unit tier's own flags, which every leg passes.
TIER_FLAGS = ('--ignore=tests/integration', '--ignore-glob=*_runtime.py', '--ignore-glob=*_regression.py')
#: A ``python`` that appends each argument on a line of ``$ARGS_FILE``, then an
Expand Down Expand Up @@ -284,7 +292,7 @@ def test_every_other_leg_runs_the_rest(self) -> None:
continue
want = list(expected)
if is_experimental(leg):
# #1740: the experimental step's own worker count, 3 on GraalPy and `auto` on the rest.
# #1740: the experimental step's own worker count, 3 on GraalPy and `logical` on the rest.
want[want.index('-n') + 1] = step_env(leg, EXPERIMENTAL)['WORKERS']
# #1753: and its replacements for crashed workers, after `--dist load`.
want.insert(want.index('--dist') + 2, '--max-worker-restart=30')
Expand All @@ -297,10 +305,11 @@ def test_every_test_leg_runs_a_worker_per_logical_cpu(self) -> None:
"""#2571: ``-n logical``, as ``auto`` counts physical cores wherever psutil is installed.

PyPy's legs share the flag, and with no psutil there resolve it as ``auto`` would.
GraalPy's leg runs 3 workers (#1740).

"""
for version, (leg, argv) in self.runs.items():
if is_experimental(leg):
for version, (_, argv) in self.runs.items():
if version.startswith('graalpy'):
continue
with self.subTest(python=version):
args = pytest_args(argv)
Expand Down Expand Up @@ -391,7 +400,7 @@ def setUpClass(cls) -> None:

@staticmethod
def flags() -> 'list[str]':
return ['-q', '-n', 'auto', '--dist', 'load', '--durations=30', '--durations-min=1.0',
return ['-q', '-n', 'logical', '--dist', 'load', '--durations=30', '--durations-min=1.0',
f'--junitxml={RUNNER_TEMP}/junit.xml']

def test_the_all_selection_leaves_out_the_latest_modules(self) -> None:
Expand Down Expand Up @@ -434,7 +443,98 @@ def test_the_parity_legs_run_the_all_tier(self) -> None:
self.assertNotIn(self.latest, [leg['python-version'] for leg in found])
self.assertIn(_tiers.FIXTURE_ALL_TIER_SELECTOR, step(PARITY)['run'])
self.assertEqual(pytest_args(run_step(legs(*PARITY)[0], PARITY)),
['-q', '-n', 'auto', '--dist', 'load'] + self.all)
['-q', '-n', 'logical', '--dist', 'load'] + self.all)


def pytest_commands(run: 'str') -> 'Iterator[str]':
"""Each command of a ``run:`` script that names pytest, or sets the positional arguments a later one passes.

A continued line is one command; a comment line is none.

"""
for line in run.replace('\\\n', ' ').splitlines():
if line.lstrip().startswith('#'):
continue
for command in re.split(r';|&&|\|', line):
if 'pytest' in command.lower() or re.match(r'\s*set\s+--(?:\s|$)', command):
yield command


def worker_counts(name: 'str', spec: 'dict[str, Any]') -> 'list[tuple[str, str]]':
"""Each pytest-xdist worker count workflow ``name`` sets, with where it sets it.

A :data:`WORKERS_FLAG` in a pytest command or a ``PYTEST_ADDOPTS`` gives its value, and an
:data:`XDIST_ENV` anywhere in a job gives that name.

"""
found = [] # type: list[tuple[str, str]]

def addopts(where: 'str', env: 'Any') -> None:
value = (env or {}).get('PYTEST_ADDOPTS')
if value is not None:
found.extend((where, match.group('value')) for match in WORKERS_FLAG.finditer(str(value)))

addopts(name, spec.get('env'))
if XDIST_ENV in repr(spec.get('env')):
found.append((name, XDIST_ENV))
for job_id, body in spec['jobs'].items():
if XDIST_ENV in repr(body):
found.append((f'{name} {job_id}', XDIST_ENV))
addopts(f'{name} {job_id}', body.get('env'))
for entry in body.get('steps') or ():
where = f"{name} {job_id}: {entry.get('name')}"
addopts(where, entry.get('env'))
for command in pytest_commands(str(entry.get('run', ''))):
found.extend((where, match.group('value')) for match in WORKERS_FLAG.finditer(command))
return found


class TestEveryXdistRunCountsLogicalCPUs(unittest.TestCase):
"""#2573: every pytest-xdist worker count in the workflows but ``experimental``'s is ``logical``, as ``test``'s is.

``experimental``'s is its ``$WORKERS``, 3 on GraalPy, which :class:`TestTheStepSelectsByLeg` checks per leg.
Only the workflows are read, so the frozen copies under tests/project are left out.

"""

#: ``experimental``'s count, the one that is not ``logical``.
OWN = (f'{EXPERIMENTAL[0]} {EXPERIMENTAL[1]}: {STEP}', '"$WORKERS"')
#: ``gate``'s step, which :meth:`test_each_spelling_of_another_count_is_caught` doctors.
GATE = ('unit-tests.yml', 'gate', INTEGRATION_STEP)

@classmethod
def stray(cls, found: 'list[tuple[str, str]]') -> 'list[tuple[str, str]]':
return [entry for entry in found if entry[1] != 'logical' and entry != cls.OWN]

def doctored(self, **change: 'Any') -> 'list[tuple[str, str]]':
"""``gate``'s step with ``change`` applied, and what :func:`worker_counts` then finds astray."""
name, job_id, step_name = self.GATE
spec = copy.deepcopy(workflow(name))
entry = next(entry for entry in spec['jobs'][job_id]['steps'] if entry.get('name') == step_name)
self.assertIn('-n logical --dist load', entry['run'])
if 'flags' in change:
entry['run'] = entry['run'].replace('-n logical --dist load', change['flags'])
if 'env' in change:
entry['env'] = change['env']
return self.stray(worker_counts(name, spec))

def test_every_xdist_run_passes_n_logical(self) -> None:
found = [entry for name, spec in workflows() for entry in worker_counts(name, spec)]
self.assertEqual(self.stray(found), [])
self.assertIn(self.OWN, found)
self.assertIn((f'{self.GATE[0]} {self.GATE[1]}: {self.GATE[2]}', 'logical'), found)

def test_each_spelling_of_another_count_is_caught(self) -> None:
"""A bare ``-n auto`` among them, with no ``--dist`` after it."""
where = f'{self.GATE[0]} {self.GATE[1]}: {self.GATE[2]}'
for flags, value in (('-n auto', 'auto'), ('-nauto', 'auto'), ('-n 2 --dist load', '2'),
('--numprocesses auto', 'auto'), ('--numprocesses=auto', 'auto')):
with self.subTest(flags=flags):
self.assertEqual(self.doctored(flags=flags), [(where, value)])
with self.subTest(env='PYTEST_ADDOPTS'):
self.assertEqual(self.doctored(env={'PYTEST_ADDOPTS': '-n auto'}), [(where, 'auto')])
with self.subTest(env=XDIST_ENV):
self.assertEqual(self.doctored(env={XDIST_ENV: '2'}), [(f'{self.GATE[0]} {self.GATE[1]}', XDIST_ENV)])


def triggers(spec: 'dict[str, Any]') -> 'dict[str, Any]':
Expand Down
Loading
Loading