diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0b865dd9..0782b175 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,4 +29,5 @@ jobs: typecheck: true coverage: true coverage-min: '80' + pytest_args: "-n auto --dist loadscope" secrets: inherit diff --git a/.github/workflows/pr-00-gate.yml b/.github/workflows/pr-00-gate.yml index adbcdea1..dd9b9ed8 100644 --- a/.github/workflows/pr-00-gate.yml +++ b/.github/workflows/pr-00-gate.yml @@ -43,15 +43,16 @@ jobs: uses: stranske/Workflows/.github/workflows/reusable-10-ci-python.yml@main with: python-versions: '["3.11"]' - coverage-min: "80" + # PR Gate is optimized for fast feedback. Full coverage enforcement runs on main. + coverage: false format_check: false # Using ruff for formatting # Keep PR feedback fast by skipping heavy integration suites here. # Full test coverage remains enforced on main branch CI. - pytest_args: "--ignore=tests/integration --ignore=tests/integrations" + pytest_markers: "not release" + pytest_args: "-n auto --dist loadscope --ignore=tests/integration --ignore=tests/integrations" working-directory: "." artifact-prefix: "gate-" - # Enable soft coverage gate for trend tracking and hotspot reporting - enable-soft-gate: true + enable-soft-gate: false secrets: inherit summary: diff --git a/.github/workflows/release-e2e.yml b/.github/workflows/release-e2e.yml new file mode 100644 index 00000000..64b66430 --- /dev/null +++ b/.github/workflows/release-e2e.yml @@ -0,0 +1,51 @@ +# Runs slow release/packaging validation outside the PR Gate critical path. +# Trigger modes: +# - Nightly schedule (main branch) +# - Manual dispatch +# - PR label "run-release" (runs against PR head SHA) + +name: Release E2E + +on: + schedule: + - cron: "15 3 * * *" # ~03:15 UTC nightly + workflow_dispatch: + pull_request: + types: [labeled] + +concurrency: + group: release-e2e-${{ github.event.pull_request.number || github.ref_name }} + cancel-in-progress: true + +jobs: + release-tests: + name: Release / Packaging Tests + if: >- + ${{ + github.event_name != 'pull_request' || + github.event.label.name == 'run-release' + }} + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + set -euo pipefail + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e ".[dev]" + + - name: Run release-marked tests + run: | + set -euo pipefail + pytest -q -m release --durations=10 diff --git a/AGENTS.md b/AGENTS.md index a48fa230..48072d19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,38 @@ Never silently drop exposures. If a new counterparty appears and there is no mat - Do not edit `.github/workflows/**` unless explicitly operating under a high-privilege environment and the task requires it. - If workflow changes are needed, fix them in **stranske/Workflows** then sync. +## CI test situation (what runs when) + +This repo is tuned so PR Gate stays reasonably fast by skipping the most expensive suites. + +### PR Gate + +PR Gate uses `.github/workflows/pr-00-gate.yml`: + +- Runs pytest **in parallel** with xdist (`-n auto --dist loadscope`). +- Runs pytest **without coverage**. +- Skips `tests/integration/` and `tests/integrations/`. +- Skips `release`-marked tests (`pytest_markers: "not release"`). + +### Main CI (push to `main`) + +Main CI uses `.github/workflows/ci.yml`: + +- Runs pytest **with coverage** and enforces `coverage-min`. +- Runs pytest **in parallel** with xdist. +- Runs the full test suite. + +### Release tests (nightly or label) + +The slowest release/packaging checks are isolated behind the `release` marker and can be triggered via: + +- Nightly schedule (`.github/workflows/release-e2e.yml`) +- PR label: `run-release` + +### Agent guidance + +When a PR touches release/packaging mechanics (e.g., `release.spec`, `pyinstaller_runtime_hook.py`, templates/config bundling), make sure `release` tests run by applying the `run-release` label. + ## Agent guardrails (must follow) - Also read: `.github/codex/AGENT_INSTRUCTIONS.md` diff --git a/CLAUDE.md b/CLAUDE.md index 685aac40..ba489457 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,6 +49,53 @@ When an issue is labeled `agent:codex`: ## Common Issues +## CI Test Policy (PR Gate vs CI vs Release E2E) + +This repo intentionally does **not** run the full test surface area on every PR Gate run. + +### PR Gate (`.github/workflows/pr-00-gate.yml`) + +Goal: fast feedback for most PRs. + +- Runs pytest **in parallel** (xdist): `-n auto --dist loadscope` +- Runs pytest **without coverage** (`coverage: false`) +- Skips integration directories: + - `tests/integration/` + - `tests/integrations/` +- Skips **release/packaging** tests via marker: `pytest_markers: "not release"` + +These suites will NOT run on PR Gate unless you run them manually (see below). + +### Main-branch CI (`.github/workflows/ci.yml`) + +Goal: enforce full quality gates on `main`. + +- Runs pytest **with coverage** (`coverage: true`, `coverage-min` enforced) +- Runs pytest **in parallel** (xdist): `-n auto --dist loadscope` +- Runs the full test suite (including integration dirs and `release` tests) + +### Release/Packaging E2E (`.github/workflows/release-e2e.yml`) + +Goal: keep slow PyInstaller + packaged-executable checks out of PR Gate. + +- Runs nightly on `main` +- Runs on PRs when the PR is labeled: `run-release` +- Executes only the tests marked `release`: `pytest -m release` + +### How to run skipped suites locally + +```bash +# Fast PR-gate-like run (parallel, no coverage, skip release + integration dirs) +pytest -q -n auto --dist loadscope -m "not release" \ + --ignore=tests/integration --ignore=tests/integrations + +# Release / packaging validation (PyInstaller + packaged executable) +pytest -q -m release + +# Integration directories (if you need them on a PR) +pytest -q tests/integration tests/integrations +``` + ### Workflow fails with "workflow file issue" - A reusable workflow is being called that doesn't exist - Check Workflows repo has the required `reusable-*.yml` file diff --git a/pyproject.toml b/pyproject.toml index 19324950..ba0c5a58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ app = [] dev = [ "pytest==9.0.2", "pytest-cov==7.0.0", + "pytest-xdist==3.8.0", "pandas>=2.2,<3", "black==24.10.0", "ruff==0.15.1", @@ -55,6 +56,9 @@ pythonpath = ["src"] python_files = ["test_*.py", "*_test.py"] python_classes = ["Test*"] python_functions = ["test_*"] +markers = [ + "release: slow release/packaging tests (PyInstaller build + packaged executable)", +] [tool.coverage.run] source = ["src"] diff --git a/requirements-dev.lock b/requirements-dev.lock index 3ea76249..c69314fa 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -1,46 +1,93 @@ # This file was autogenerated by uv via the following command: # uv pip compile pyproject.toml --extra dev --universal --output-file requirements-dev.lock -colorama==0.4.6 ; sys_platform == 'win32' - # via pytest +annotated-types==0.7.0 + # via pydantic black==24.10.0 # via my-project (pyproject.toml) +click==8.3.1 + # via black +colorama==0.4.6 ; sys_platform == 'win32' + # via + # click + # pytest coverage==7.13.4 # via pytest-cov +et-xmlfile==2.0.0 + # via openpyxl +execnet==2.1.2 + # via pytest-xdist iniconfig==2.3.0 # via pytest +librt==0.8.0 ; platform_python_implementation != 'PyPy' + # via mypy +lxml==6.0.2 + # via python-pptx mypy==1.19.1 # via my-project (pyproject.toml) mypy-extensions==1.1.0 - # via mypy + # via + # black + # mypy +numpy==2.4.2 + # via pandas openpyxl==3.1.5 # via my-project (pyproject.toml) packaging==25.0 - # via pytest + # via + # black + # pytest pandas==2.2.3 # via my-project (pyproject.toml) pathspec==0.12.1 - # via mypy -pydantic==2.7.4 - # via my-project (pyproject.toml) -python-pptx==1.0.2 - # via my-project (pyproject.toml) -pyyaml==6.0.2 - # via my-project (pyproject.toml) + # via + # black + # mypy +pillow==12.1.1 + # via python-pptx +platformdirs==4.9.1 + # via black pluggy==1.6.0 # via # pytest # pytest-cov +pydantic==2.7.4 + # via my-project (pyproject.toml) +pydantic-core==2.18.4 + # via pydantic pygments==2.19.2 # via pytest pytest==9.0.2 # via # my-project (pyproject.toml) # pytest-cov + # pytest-xdist pytest-cov==7.0.0 # via my-project (pyproject.toml) +pytest-xdist==3.8.0 + # via my-project (pyproject.toml) +python-dateutil==2.9.0.post0 + # via pandas +python-pptx==1.0.2 + # via my-project (pyproject.toml) +pytz==2025.2 + # via pandas +pyyaml==6.0.2 + # via my-project (pyproject.toml) ruff==0.15.1 # via my-project (pyproject.toml) +six==1.17.0 + # via python-dateutil +tomli==2.4.0 ; python_full_version <= '3.11' + # via coverage tomlkit==0.13.3 # via my-project (pyproject.toml) typing-extensions==4.15.0 - # via mypy + # via + # mypy + # pydantic + # pydantic-core + # python-pptx +tzdata==2025.3 + # via pandas +xlsxwriter==3.2.9 + # via python-pptx diff --git a/requirements.lock b/requirements.lock index 1aa3a250..d5865282 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,50 +1,93 @@ # This file was autogenerated by uv via the following command: # uv pip compile pyproject.toml --extra dev --universal --output-file requirements.lock -colorama==0.4.6 ; sys_platform == 'win32' - # via pytest +annotated-types==0.7.0 + # via pydantic black==24.10.0 # via my-project (pyproject.toml) +click==8.3.1 + # via black +colorama==0.4.6 ; sys_platform == 'win32' + # via + # click + # pytest coverage==7.13.4 # via pytest-cov +et-xmlfile==2.0.0 + # via openpyxl +execnet==2.1.2 + # via pytest-xdist iniconfig==2.3.0 # via pytest +librt==0.8.0 ; platform_python_implementation != 'PyPy' + # via mypy +lxml==6.0.2 + # via python-pptx mypy==1.19.1 # via my-project (pyproject.toml) mypy-extensions==1.1.0 - # via mypy + # via + # black + # mypy +numpy==2.4.2 + # via pandas openpyxl==3.1.5 # via my-project (pyproject.toml) packaging==25.0 - # via pytest + # via + # black + # pytest pandas==2.2.3 # via my-project (pyproject.toml) pathspec==0.12.1 - # via mypy -pydantic==2.7.4 - # via my-project (pyproject.toml) -python-pptx==1.0.2 - # via my-project (pyproject.toml) -pyyaml==6.0.2 - # via my-project (pyproject.toml) + # via + # black + # mypy +pillow==12.1.1 + # via python-pptx +platformdirs==4.9.1 + # via black pluggy==1.6.0 # via # pytest # pytest-cov +pydantic==2.7.4 + # via my-project (pyproject.toml) +pydantic-core==2.18.4 + # via pydantic pygments==2.19.2 # via pytest pytest==9.0.2 # via # my-project (pyproject.toml) # pytest-cov + # pytest-xdist pytest-cov==7.0.0 # via my-project (pyproject.toml) +pytest-xdist==3.8.0 + # via my-project (pyproject.toml) +python-dateutil==2.9.0.post0 + # via pandas +python-pptx==1.0.2 + # via my-project (pyproject.toml) +pytz==2025.2 + # via pandas +pyyaml==6.0.2 + # via my-project (pyproject.toml) ruff==0.15.1 # via my-project (pyproject.toml) -setuptools==79.0.1 - # required by build-system for editable install in isolated CI envs +six==1.17.0 + # via python-dateutil +tomli==2.4.0 ; python_full_version <= '3.11' + # via coverage tomlkit==0.13.3 # via my-project (pyproject.toml) typing-extensions==4.15.0 - # via mypy -wheel==0.45.1 - # required by build-system for editable install in isolated CI envs + # via + # mypy + # pydantic + # pydantic-core + # python-pptx +tzdata==2025.3 + # via pandas +xlsxwriter==3.2.9 + # via python-pptx diff --git a/sitecustomize.py b/sitecustomize.py index de7e0f0e..160647c2 100644 --- a/sitecustomize.py +++ b/sitecustomize.py @@ -2,6 +2,7 @@ from __future__ import annotations +import importlib.util import os import sys from pathlib import Path @@ -36,11 +37,18 @@ def _strip_pytest_xdist_args(argv: list[str]) -> list[str]: return stripped +def _xdist_is_available() -> bool: + return importlib.util.find_spec("xdist") is not None + + def _disable_xdist_cli_flags_for_pytest() -> None: if not _is_pytest_invocation(sys.argv): return if os.environ.get("COUNTER_RISK_KEEP_XDIST_ARGS") == "1": return + if _xdist_is_available() and os.environ.get("COUNTER_RISK_STRIP_XDIST_ARGS") != "1": + return + sys.argv[:] = _strip_pytest_xdist_args(sys.argv) diff --git a/tests/integration/test_packaged_executable_assets.py b/tests/integration/test_packaged_executable_assets.py index 2bdae2c8..44c313e8 100644 --- a/tests/integration/test_packaged_executable_assets.py +++ b/tests/integration/test_packaged_executable_assets.py @@ -11,6 +11,8 @@ import pytest import yaml # type: ignore[import-untyped] +pytestmark = pytest.mark.release + _REQUIRED_FIXTURE_KEYS = ( "mosers_all_programs_xlsx", "mosers_ex_trend_xlsx", diff --git a/tests/pipeline/test_run_pipeline.py b/tests/pipeline/test_run_pipeline.py index b5a7d687..d0ef4d9e 100644 --- a/tests/pipeline/test_run_pipeline.py +++ b/tests/pipeline/test_run_pipeline.py @@ -267,7 +267,9 @@ def test_run_pipeline_generates_all_programs_mosers_from_raw_nisa_input( # Keep the raw-NISA generation path real, but stub downstream heavy stages # that are covered by dedicated integration tests. - monkeypatch.setattr("counter_risk.pipeline.run._parse_inputs", lambda _: _minimal_parsed_by_variant()) + monkeypatch.setattr( + "counter_risk.pipeline.run._parse_inputs", lambda _: _minimal_parsed_by_variant() + ) monkeypatch.setattr( "counter_risk.pipeline.run._update_historical_outputs", lambda *, run_dir, config, parsed_by_variant, as_of_date, warnings: [], diff --git a/tests/test_release_spec.py b/tests/test_release_spec.py index 99dd3cb1..46bce21c 100644 --- a/tests/test_release_spec.py +++ b/tests/test_release_spec.py @@ -74,6 +74,7 @@ def _collect(*args: object, **kwargs: object) -> str: assert captures["collect_kwargs"]["name"] == "counter-risk" +@pytest.mark.release def test_release_spec_pyinstaller_build_outputs_expected_executable( tmp_path: Path, ) -> None: diff --git a/tests/test_sitecustomize.py b/tests/test_sitecustomize.py index feaf4f92..51b444ec 100644 --- a/tests/test_sitecustomize.py +++ b/tests/test_sitecustomize.py @@ -24,3 +24,46 @@ def test_strip_pytest_xdist_args_keeps_other_args() -> None: stripped = sitecustomize._strip_pytest_xdist_args(argv) assert stripped == argv + + +def test_disable_xdist_cli_flags_for_pytest_keeps_when_xdist_available(monkeypatch) -> None: + argv = ["pytest", "-q", "-n", "auto", "--dist", "loadscope"] + monkeypatch.setattr(sitecustomize, "sys", type("_Sys", (), {"argv": argv})()) + monkeypatch.setattr(sitecustomize, "_xdist_is_available", lambda: True) + monkeypatch.delenv("COUNTER_RISK_KEEP_XDIST_ARGS", raising=False) + monkeypatch.delenv("COUNTER_RISK_STRIP_XDIST_ARGS", raising=False) + + sitecustomize._disable_xdist_cli_flags_for_pytest() + assert sitecustomize.sys.argv == argv + + +def test_disable_xdist_cli_flags_for_pytest_strips_when_xdist_missing(monkeypatch) -> None: + argv = ["pytest", "-q", "-n", "auto", "--dist", "loadscope"] + monkeypatch.setattr(sitecustomize, "sys", type("_Sys", (), {"argv": argv})()) + monkeypatch.setattr(sitecustomize, "_xdist_is_available", lambda: False) + monkeypatch.delenv("COUNTER_RISK_KEEP_XDIST_ARGS", raising=False) + monkeypatch.delenv("COUNTER_RISK_STRIP_XDIST_ARGS", raising=False) + + sitecustomize._disable_xdist_cli_flags_for_pytest() + assert sitecustomize.sys.argv == ["pytest", "-q"] + + +def test_disable_xdist_cli_flags_for_pytest_strips_when_env_set(monkeypatch) -> None: + argv = ["pytest", "-q", "-n", "auto", "--dist", "loadscope"] + monkeypatch.setattr(sitecustomize, "sys", type("_Sys", (), {"argv": argv})()) + monkeypatch.setattr(sitecustomize, "_xdist_is_available", lambda: True) + monkeypatch.setenv("COUNTER_RISK_STRIP_XDIST_ARGS", "1") + + sitecustomize._disable_xdist_cli_flags_for_pytest() + assert sitecustomize.sys.argv == ["pytest", "-q"] + + +def test_disable_xdist_cli_flags_for_pytest_keeps_when_keep_env_set(monkeypatch) -> None: + argv = ["pytest", "-q", "-n", "auto", "--dist", "loadscope"] + monkeypatch.setattr(sitecustomize, "sys", type("_Sys", (), {"argv": argv})()) + monkeypatch.setattr(sitecustomize, "_xdist_is_available", lambda: False) + monkeypatch.setenv("COUNTER_RISK_KEEP_XDIST_ARGS", "1") + monkeypatch.delenv("COUNTER_RISK_STRIP_XDIST_ARGS", raising=False) + + sitecustomize._disable_xdist_cli_flags_for_pytest() + assert sitecustomize.sys.argv == argv diff --git a/tests/test_validate_release_workflow_yaml.py b/tests/test_validate_release_workflow_yaml.py index 4a410cc4..ae9d5f22 100644 --- a/tests/test_validate_release_workflow_yaml.py +++ b/tests/test_validate_release_workflow_yaml.py @@ -41,7 +41,8 @@ def _write_workflow(path: Path, *, extra: str = "") -> None: name: release-${{ env.RELEASE_VERSION }} path: release/${{ env.RELEASE_VERSION }}/ retention-days: 7 -""" + extra, +""" + + extra, encoding="utf-8", )