diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8a969e540..0c8148daa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,7 +138,9 @@ jobs: test: needs: changes runs-on: ${{ needs.changes.outputs.runner == 'korvid-runners' && 'korvid-runners' || 'ubuntu-latest' }} - # The full suite under coverage takes 39-46 minutes on Python 3.12 (#454). + # In one process the suite under coverage took 39-46 minutes on Python + # 3.12 (#454); it now runs on four xdist workers (#468), and the limit + # stays as headroom for a loaded runner. timeout-minutes: 60 strategy: matrix: @@ -159,7 +161,7 @@ jobs: - run: uvx deptry src/ # A docs-only change still gets a full suite run, on one version. - if: needs.changes.outputs.code == 'true' || matrix.python-version == '3.12' - run: uv run pytest --cov --cov-fail-under=80 + run: uv run pytest -n 4 --cov --cov-fail-under=80 windows-test: needs: changes @@ -199,7 +201,7 @@ jobs: # The docs landing harness ran above in a fresh process; do not execute # it a second time in the long-lived Windows pytest process. - if: needs.changes.outputs.code == 'true' - run: uv run pytest -q --ignore=tests/windows/test_native_terminal.py --ignore=tests/test_docs_landing_behavior.py --deselect=tests/core/test_audit.py::test_concurrent_appends_across_instances --randomly-seed=${{ github.run_id }} + run: uv run pytest -n 4 -q --ignore=tests/windows/test_native_terminal.py --ignore=tests/test_docs_landing_behavior.py --deselect=tests/core/test_audit.py::test_concurrent_appends_across_instances --randomly-seed=${{ github.run_id }} pre-commit: needs: changes diff --git a/docs/dev/quality-gates.md b/docs/dev/quality-gates.md index be65968fd..9a3e7c03b 100644 --- a/docs/dev/quality-gates.md +++ b/docs/dev/quality-gates.md @@ -137,7 +137,7 @@ gate wastes more time than it saves. | job | what it establishes | |---|---| | `changes` | classifies the diff so a docs-only change skips redundant test legs | -| `test` (3.11, 3.12, 3.13) | ruff, `ruff format --check`, mypy, `tach`, `deptry`, `pytest --cov --cov-fail-under=80` | +| `test` (3.11, 3.12, 3.13) | ruff, `ruff format --check`, mypy, `tach`, `deptry`, `pytest -n 4 --cov --cov-fail-under=80` (four xdist workers; coverage is combined across them) | | `windows-test` (3.12) | OS-specific behaviour — path handling, terminal, keyring | | `pre-commit` | the layer-1 hooks over every file | | `security` | `pip-audit` on the locked runtime resolution **including every extra**, `zizmor` on the workflows | diff --git a/tests/k8s/test_pulse.py b/tests/k8s/test_pulse.py index 7cd6d7343..d3e1f1af2 100644 --- a/tests/k8s/test_pulse.py +++ b/tests/k8s/test_pulse.py @@ -247,6 +247,9 @@ async def refresh( ("gzip", gzip.compress(b'{"items": []}') + b"trailing"), ("br", b"unsupported"), ], + # Named, not derived from the payload: a gzip header carries the time it + # was written, and xdist aborts when its workers collect different IDs. + ids=["gzip-not-compressed", "gzip-truncated", "gzip-trailing-bytes", "br-unsupported"], ) async def test_invalid_compression_fails_closed_and_closes_response( pulse: ModuleType, encoding: str, payload: bytes diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index 1e05ee83e..c70c3709d 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -4,6 +4,7 @@ import json import os +import shlex import shutil import subprocess from collections.abc import Mapping @@ -72,7 +73,9 @@ WINDOWS_SEED = "${{ github.run_id }}" CI_JOB_TIMEOUTS = { "changes": 10, - # The full suite under coverage takes 39-46 minutes on Python 3.12 (#454). + # In one process the suite under coverage took 39-46 minutes on Python + # 3.12 (#454); it now runs on four xdist workers (#468), and the limit + # stays as headroom for a loaded runner. "test": 60, "windows-test": 45, "pre-commit": 20, @@ -431,6 +434,60 @@ def test_windows_pytest_processes_print_and_share_one_deterministic_seed() -> No assert sum(run.count("--randomly-seed=") for run in runs) == 2 +def test_the_full_suite_runs_on_four_xdist_workers() -> None: + """In one process the suite took the 3.12 coverage job past 45 minutes + (#454); four workers run it, with the same coverage, in under a third of + the time (#468). A fixed count rather than `auto`: a korvid-runners pod + requests one CPU on a shared eight-vCPU node.""" + jobs = _jobs(CI_WORKFLOW) + linux_suite = [run for run in _run_steps(jobs["test"]) if run.startswith("uv run pytest")] + windows_suite = [ + run + for run in _run_steps(jobs["windows-test"]) + if run.startswith("uv run pytest") + and "--ignore=tests/windows/test_native_terminal.py" in run + ] + + assert linux_suite == ["uv run pytest -n 4 --cov --cov-fail-under=80"] + assert len(windows_suite) == 1 + assert windows_suite[0].startswith("uv run pytest -n 4 -q ") + assert _xdist_worker_counts(windows_suite[0]) == ["4"] + + +def _xdist_worker_counts(run: str) -> list[str]: + """Every worker count `run` passes to pytest-xdist, in order. + + xdist takes the last of repeated `-n` options, so a pin has to see all of + them, in each spelling pytest accepts. + """ + tokens = shlex.split(run) + counts: list[str] = [] + for index, token in enumerate(tokens): + if token in ("-n", "--numprocesses"): + counts.append(tokens[index + 1] if index + 1 < len(tokens) else "") + elif token.startswith("--numprocesses="): + counts.append(token.partition("=")[2]) + elif token.startswith("-n") and not token.startswith("--"): + counts.append(token[2:]) + return counts + + +@pytest.mark.parametrize( + ("run", "counts"), + [ + ("uv run pytest -n 4 -q", ["4"]), + ("uv run pytest -n 4 -q --ignore=tests/x.py -n 1", ["4", "1"]), + ("uv run pytest -n4 -q", ["4"]), + ("uv run pytest --numprocesses 4", ["4"]), + ("uv run pytest --numprocesses=4 -n 2", ["4", "2"]), + ("uv run pytest -q --no-header", []), + ], + ids=["spaced", "repeated", "attached", "long", "long-then-short", "none"], +) +def test_the_worker_count_check_sees_every_n_option(run: str, counts: list[str]) -> None: + assert _xdist_worker_counts(run) == counts + + def test_experimental_ty_job_is_honestly_advisory() -> None: job = _jobs(CI_WORKFLOW)["ty-experimental"] runs = _run_steps(job) diff --git a/tests/test_platforms.py b/tests/test_platforms.py index e62b6659d..d49675e30 100644 --- a/tests/test_platforms.py +++ b/tests/test_platforms.py @@ -233,7 +233,7 @@ def test_ci_workflow_defines_the_required_windows_test_job() -> None: assert "uv sync --locked --dev --all-extras" in runs audit_regression = "tests/core/test_audit.py::test_concurrent_appends_across_instances" full_suite = ( - "uv run pytest -q --ignore=tests/windows/test_native_terminal.py" + "uv run pytest -n 4 -q --ignore=tests/windows/test_native_terminal.py" " --ignore=tests/test_docs_landing_behavior.py" f" --deselect={audit_regression}" " --randomly-seed=${{ github.run_id }}" @@ -268,7 +268,7 @@ def test_ci_windows_docs_harness_runs_once_in_fresh_process() -> None: f"uv run pytest -p no:tach tests/windows/test_native_terminal.py {audit_regression} -q" ) full_suite = ( - "uv run pytest -q --ignore=tests/windows/test_native_terminal.py" + "uv run pytest -n 4 -q --ignore=tests/windows/test_native_terminal.py" " --ignore=tests/test_docs_landing_behavior.py" f" --deselect={audit_regression}" " --randomly-seed=${{ github.run_id }}"