Skip to content

Commit b2e17af

Browse files
authored
Ci test selection (#502)
* Remove -x from mpl pytest runs * ci: add test impact selection
1 parent 38d5df5 commit b2e17af

File tree

5 files changed

+304
-13
lines changed

5 files changed

+304
-13
lines changed

.github/workflows/build-ultraplot.yml

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ on:
88
matplotlib-version:
99
required: true
1010
type: string
11+
test-mode:
12+
required: false
13+
type: string
14+
default: full
15+
test-nodeids:
16+
required: false
17+
type: string
18+
default: ""
1119

1220
env:
1321
LC_ALL: en_US.UTF-8
@@ -21,6 +29,9 @@ jobs:
2129
defaults:
2230
run:
2331
shell: bash -el {0}
32+
env:
33+
TEST_MODE: ${{ inputs.test-mode }}
34+
TEST_NODEIDS: ${{ inputs.test-nodeids }}
2435
steps:
2536
- uses: actions/checkout@v6
2637
with:
@@ -43,7 +54,11 @@ jobs:
4354
4455
- name: Test Ultraplot
4556
run: |
46-
pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ultraplot
57+
if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then
58+
pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ${TEST_NODEIDS}
59+
else
60+
pytest -n auto --cov=ultraplot --cov-branch --cov-report term-missing --cov-report=xml ultraplot
61+
fi
4762
4863
- name: Upload coverage reports to Codecov
4964
uses: codecov/codecov-action@v5
@@ -56,6 +71,8 @@ jobs:
5671
runs-on: ubuntu-latest
5772
env:
5873
IS_PR: ${{ github.event_name == 'pull_request' }}
74+
TEST_MODE: ${{ inputs.test-mode }}
75+
TEST_NODEIDS: ${{ inputs.test-nodeids }}
5976
defaults:
6077
run:
6178
shell: bash -el {0}
@@ -102,10 +119,17 @@ jobs:
102119
103120
# Generate the baseline images and hash library
104121
python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')"
105-
pytest -W ignore \
106-
--mpl-generate-path=./ultraplot/tests/baseline/ \
107-
--mpl-default-style="./ultraplot.yml"\
108-
ultraplot/tests
122+
if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then
123+
pytest -W ignore \
124+
--mpl-generate-path=./ultraplot/tests/baseline/ \
125+
--mpl-default-style="./ultraplot.yml" \
126+
${TEST_NODEIDS}
127+
else
128+
pytest -W ignore \
129+
--mpl-generate-path=./ultraplot/tests/baseline/ \
130+
--mpl-default-style="./ultraplot.yml" \
131+
ultraplot/tests
132+
fi
109133
110134
# Return to the PR branch for the rest of the job
111135
if [ -n "${{ github.event.pull_request.base.sha }}" ]; then
@@ -120,13 +144,23 @@ jobs:
120144
121145
mkdir -p results
122146
python -c "import ultraplot as plt; plt.config.Configurator()._save_yaml('ultraplot.yml')"
123-
pytest -W ignore \
124-
--mpl \
125-
--mpl-baseline-path=./ultraplot/tests/baseline \
126-
--mpl-results-path=./results/ \
127-
--mpl-generate-summary=html \
128-
--mpl-default-style="./ultraplot.yml" \
129-
ultraplot/tests
147+
if [ "${TEST_MODE}" = "selected" ] && [ -n "${TEST_NODEIDS}" ]; then
148+
pytest -W ignore \
149+
--mpl \
150+
--mpl-baseline-path=./ultraplot/tests/baseline \
151+
--mpl-results-path=./results/ \
152+
--mpl-generate-summary=html \
153+
--mpl-default-style="./ultraplot.yml" \
154+
${TEST_NODEIDS}
155+
else
156+
pytest -W ignore \
157+
--mpl \
158+
--mpl-baseline-path=./ultraplot/tests/baseline \
159+
--mpl-results-path=./results/ \
160+
--mpl-generate-summary=html \
161+
--mpl-default-style="./ultraplot.yml" \
162+
ultraplot/tests
163+
fi
130164
131165
# Return the html output of the comparison even if failed
132166
- name: Upload comparison failures

.github/workflows/main.yml

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,60 @@ jobs:
1919
python:
2020
- 'ultraplot/**'
2121
22+
select-tests:
23+
runs-on: ubuntu-latest
24+
needs:
25+
- run-if-changes
26+
if: always() && needs.run-if-changes.outputs.run == 'true'
27+
outputs:
28+
mode: ${{ steps.select.outputs.mode }}
29+
tests: ${{ steps.select.outputs.tests }}
30+
steps:
31+
- uses: actions/checkout@v6
32+
with:
33+
fetch-depth: 0
34+
35+
- name: Prepare workspace
36+
run: mkdir -p .ci
37+
38+
- name: Restore test map cache
39+
id: restore-map
40+
uses: actions/cache/restore@v4
41+
with:
42+
path: .ci/test-map.json
43+
key: test-map-${{ github.event.pull_request.base.sha }}
44+
restore-keys: |
45+
test-map-
46+
47+
- name: Select impacted tests
48+
id: select
49+
run: |
50+
if [ "${{ github.event_name }}" != "pull_request" ]; then
51+
echo "mode=full" >> $GITHUB_OUTPUT
52+
echo "tests=" >> $GITHUB_OUTPUT
53+
exit 0
54+
fi
55+
56+
git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.sha }} > .ci/changed.txt
57+
58+
python tools/ci/select_tests.py \
59+
--map .ci/test-map.json \
60+
--changed-files .ci/changed.txt \
61+
--output .ci/selection.json \
62+
--always-full 'pyproject.toml' \
63+
--always-full 'environment.yml' \
64+
--always-full 'ultraplot/__init__.py' \
65+
--ignore 'docs/**' \
66+
--ignore 'README.rst'
67+
68+
python - <<'PY' > .ci/selection.out
69+
import json
70+
data = json.load(open(".ci/selection.json", "r", encoding="utf-8"))
71+
print(f"mode={data['mode']}")
72+
print("tests=" + " ".join(data.get("tests", [])))
73+
PY
74+
cat .ci/selection.out >> $GITHUB_OUTPUT
75+
2276
get-versions:
2377
runs-on: ubuntu-latest
2478
needs:
@@ -121,7 +175,8 @@ jobs:
121175
needs:
122176
- get-versions
123177
- run-if-changes
124-
if: always() && needs.run-if-changes.outputs.run == 'true' && needs.get-versions.result == 'success'
178+
- select-tests
179+
if: always() && needs.run-if-changes.outputs.run == 'true' && needs.get-versions.result == 'success' && needs.select-tests.result == 'success'
125180
strategy:
126181
matrix:
127182
python-version: ${{ fromJson(needs.get-versions.outputs.python-versions) }}
@@ -134,6 +189,8 @@ jobs:
134189
with:
135190
python-version: ${{ matrix.python-version }}
136191
matplotlib-version: ${{ matrix.matplotlib-version }}
192+
test-mode: ${{ needs.select-tests.outputs.mode }}
193+
test-nodeids: ${{ needs.select-tests.outputs.tests }}
137194

138195
build-success:
139196
needs:

.github/workflows/test-map.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
name: Build Test Map
2+
on:
3+
push:
4+
branches: [main]
5+
schedule:
6+
- cron: "0 3 * * *"
7+
workflow_dispatch:
8+
9+
jobs:
10+
build-map:
11+
runs-on: ubuntu-latest
12+
timeout-minutes: 90
13+
defaults:
14+
run:
15+
shell: bash -el {0}
16+
steps:
17+
- uses: actions/checkout@v6
18+
with:
19+
fetch-depth: 0
20+
21+
- uses: mamba-org/setup-micromamba@v2.0.7
22+
with:
23+
environment-file: ./environment.yml
24+
init-shell: bash
25+
create-args: >-
26+
--verbose
27+
python=3.11
28+
matplotlib=3.9
29+
cache-environment: true
30+
cache-downloads: false
31+
32+
- name: Build Ultraplot
33+
run: |
34+
pip install --no-build-isolation --no-deps .
35+
36+
- name: Generate test coverage map
37+
run: |
38+
mkdir -p .ci
39+
pytest -n auto --cov=ultraplot --cov-branch --cov-context=test --cov-report= ultraplot
40+
python tools/ci/build_test_map.py --coverage-file .coverage --output .ci/test-map.json --root .
41+
42+
- name: Cache test map
43+
uses: actions/cache@v4
44+
with:
45+
path: .ci/test-map.json
46+
key: test-map-${{ github.sha }}

tools/ci/build_test_map.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import json
6+
import os
7+
from datetime import datetime, timezone
8+
from pathlib import Path
9+
10+
11+
def build_map(coverage_file: str, repo_root: str) -> dict[str, list[str]]:
12+
try:
13+
from coverage import Coverage
14+
except Exception as exc: # pragma: no cover - diagnostic path
15+
raise SystemExit(
16+
f"coverage.py is required to build the test map: {exc}"
17+
) from exc
18+
19+
cov = Coverage(data_file=coverage_file)
20+
cov.load()
21+
data = cov.get_data()
22+
23+
files_map: dict[str, set[str]] = {}
24+
for filename in data.measured_files():
25+
if not filename:
26+
continue
27+
rel = os.path.relpath(filename, repo_root)
28+
if rel.startswith(".."):
29+
continue
30+
try:
31+
contexts_by_line = data.contexts_by_lineno(filename)
32+
except Exception:
33+
continue
34+
35+
contexts = set()
36+
for ctxs in contexts_by_line.values():
37+
if ctxs:
38+
contexts.update(ctxs)
39+
if contexts:
40+
files_map[rel] = contexts
41+
42+
return {path: sorted(contexts) for path, contexts in files_map.items()}
43+
44+
45+
def main() -> int:
46+
parser = argparse.ArgumentParser(
47+
description="Build a test impact map from coverage contexts."
48+
)
49+
parser.add_argument("--coverage-file", default=".coverage")
50+
parser.add_argument("--output", required=True)
51+
parser.add_argument("--root", default=".")
52+
args = parser.parse_args()
53+
54+
repo_root = os.path.abspath(args.root)
55+
mapping = {
56+
"generated_at": datetime.now(timezone.utc).isoformat(),
57+
"files": build_map(args.coverage_file, repo_root),
58+
}
59+
60+
output_path = Path(args.output)
61+
output_path.parent.mkdir(parents=True, exist_ok=True)
62+
with output_path.open("w", encoding="utf-8") as f:
63+
json.dump(mapping, f, indent=2, sort_keys=True)
64+
65+
return 0
66+
67+
68+
if __name__ == "__main__":
69+
raise SystemExit(main())

tools/ci/select_tests.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import json
6+
from fnmatch import fnmatch
7+
from pathlib import Path
8+
9+
10+
def load_map(path: str) -> dict[str, list[str]] | None:
11+
map_path = Path(path)
12+
if not map_path.is_file():
13+
return None
14+
with map_path.open("r", encoding="utf-8") as f:
15+
data = json.load(f)
16+
return data.get("files", {})
17+
18+
19+
def read_changed_files(path: str) -> list[str]:
20+
changed_path = Path(path)
21+
if not changed_path.is_file():
22+
return []
23+
return [
24+
line.strip()
25+
for line in changed_path.read_text(encoding="utf-8").splitlines()
26+
if line.strip()
27+
]
28+
29+
30+
def matches_any(path: str, patterns: list[str]) -> bool:
31+
return any(fnmatch(path, pattern) for pattern in patterns)
32+
33+
34+
def main() -> int:
35+
parser = argparse.ArgumentParser(
36+
description="Select impacted pytest nodeids from a test map."
37+
)
38+
parser.add_argument("--map", dest="map_path", required=True)
39+
parser.add_argument("--changed-files", required=True)
40+
parser.add_argument("--output", required=True)
41+
parser.add_argument("--always-full", action="append", default=[])
42+
parser.add_argument("--ignore", action="append", default=[])
43+
parser.add_argument("--source-prefix", default="ultraplot/")
44+
parser.add_argument("--tests-prefix", default="ultraplot/tests/")
45+
args = parser.parse_args()
46+
47+
files_map = load_map(args.map_path)
48+
changed_files = read_changed_files(args.changed_files)
49+
50+
result = {"mode": "full", "tests": []}
51+
if not files_map or not changed_files:
52+
Path(args.output).write_text(json.dumps(result, indent=2), encoding="utf-8")
53+
return 0
54+
55+
tests = set()
56+
for path in changed_files:
57+
path = path.replace("\\", "/")
58+
if matches_any(path, args.ignore):
59+
continue
60+
if matches_any(path, args.always_full):
61+
tests.clear()
62+
result["mode"] = "full"
63+
break
64+
if path.startswith(args.tests_prefix):
65+
tests.add(path)
66+
continue
67+
if path in files_map:
68+
tests.update(files_map[path])
69+
continue
70+
if path.startswith(args.source_prefix):
71+
tests.clear()
72+
result["mode"] = "full"
73+
break
74+
75+
if tests:
76+
result["mode"] = "selected"
77+
result["tests"] = sorted(tests)
78+
79+
Path(args.output).parent.mkdir(parents=True, exist_ok=True)
80+
Path(args.output).write_text(json.dumps(result, indent=2), encoding="utf-8")
81+
return 0
82+
83+
84+
if __name__ == "__main__":
85+
raise SystemExit(main())

0 commit comments

Comments
 (0)