diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 597d540d..959fbe5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,8 +30,11 @@ jobs: test: runs-on: ubuntu-latest - # Slowest of 12 successful runs, 2026-10-05..08: 7.2 minutes. Fail a hung - # job instead of holding a shared runner for GitHub's 6-hour default. + # Serially, the test job on main took 29.5 minutes at b0c8f2e and hit + # this limit at ec5413c (2026-11-02). In parallel it took 12.3 minutes + # with -n logical (4 workers, run 41526298328), and 12.5 to 16.2 minutes + # in three attempts with -n auto (run 41490222217). Fail a hung job + # instead of holding a shared runner for GitHub's 6-hour default. timeout-minutes: 30 steps: - uses: actions/checkout@v6 @@ -44,7 +47,11 @@ jobs: python-version: "3.12" - run: pip install uv - run: uv sync --locked --extra dev - - run: uv run pytest -m "not slow" --tb=short -q + # One worker per logical CPU (-n auto would count physical cores, since + # psutil is installed); the "created: N/N workers" line gives the count. + # --dist loadfile keeps each test file on one worker; on the 4-core VM it + # ran faster than --dist load (10.1 minutes). + - run: uv run pytest -m "not slow" -n logical --dist loadfile --tb=short app: runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index d812a296..e33cbcc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,8 @@ ruff format . # Format ## Testing - All tests mock external calls (LiteLLM, PE-US API) - `pytest -m "not slow"` to skip slow tests +- `pytest -m "not slow" -n 4 --dist loadfile` to split them across four workers (CI starts one per CPU) +- Some tests read git history, so run the suite from a full clone (`git fetch --unshallow`) - Full benchmark runs are manual and expensive ## Formatting diff --git a/pyproject.toml b/pyproject.toml index 60347795..64c95e11 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dev = [ "hypothesis>=6.100", "pytest>=8.0", "pytest-cov>=5.0", + "pytest-xdist>=3.6", "ruff>=0.4", ] # The paper render environment (paper/render_paper.py, .github/workflows/paper.yml): diff --git a/tests/test_reference_audit_pins.py b/tests/test_reference_audit_pins.py index 9499e212..db82bfe8 100644 --- a/tests/test_reference_audit_pins.py +++ b/tests/test_reference_audit_pins.py @@ -513,7 +513,7 @@ def call(*args, **kwargs): @pytest.mark.parametrize( ("audit", "path"), - [(audit, path) for audit in IMPACT for path in IMPACT[audit].inputs], + [(audit, path) for audit in IMPACT for path in sorted(IMPACT[audit].inputs)], ) def test_any_refused_input_stops_the_impact_script_before_it_scores( scratch, monkeypatch, audit, path @@ -533,7 +533,7 @@ def test_any_refused_input_stops_the_impact_script_before_it_scores( @pytest.mark.parametrize( ("script", "path"), - [(script, path) for script in DIRECT for path in DIRECT[script].inputs], + [(script, path) for script in DIRECT for path in sorted(DIRECT[script].inputs)], ) def test_any_refused_input_stops_the_direct_script_before_it_writes( scratch, monkeypatch, script, path diff --git a/uv.lock b/uv.lock index 8f0e80c7..e08e329c 100644 --- a/uv.lock +++ b/uv.lock @@ -755,6 +755,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "executing" version = "2.2.1" @@ -2332,6 +2341,7 @@ dev = [ { name = "hypothesis" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-xdist" }, { name = "ruff" }, ] docs = [ @@ -2356,6 +2366,7 @@ requires-dist = [ { name = "policyengine-us", specifier = "==2.60.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, + { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.6" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, ] provides-extras = ["dev", "docs"] @@ -2772,6 +2783,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"