Skip to content

Commit 97f4219

Browse files
committed
Add core Python/Matplotlib version contract checks
Introduce a shared tools/ci/version_support.py helper that derives the supported Python versions, supported Matplotlib versions, and the core CI test matrix directly from pyproject.toml. This removes the duplicated inline parser from the main workflow and gives the project a single source of truth for the version contract that matters most to UltraPlot. Add ultraplot/tests/test_core_versions.py to assert that Python classifiers stay aligned with requires-python, that the matrix workflow uses the shared helper, that the test-map workflow stays pinned to the oldest supported Python/Matplotlib pair, and that the publish workflow builds with a supported Python version. Also expand the PR change filter so workflow, tool, and version-policy changes still trigger the relevant checks.
1 parent 513083e commit 97f4219

4 files changed

Lines changed: 193 additions & 101 deletions

File tree

.github/workflows/main.yml

Lines changed: 10 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ jobs:
1818
filters: |
1919
python:
2020
- 'ultraplot/**'
21+
- 'pyproject.toml'
22+
- 'environment.yml'
23+
- '.github/workflows/**'
24+
- 'tools/ci/**'
2125
2226
select-tests:
2327
runs-on: ubuntu-latest
@@ -52,7 +56,7 @@ jobs:
5256
init-shell: bash
5357
create-args: >-
5458
--verbose
55-
python=3.11
59+
python=3.10
5660
matplotlib=3.9
5761
cache-environment: true
5862
cache-downloads: false
@@ -126,107 +130,13 @@ jobs:
126130
with:
127131
python-version: "3.11"
128132

129-
- name: Install dependencies
130-
run: pip install tomli
131-
132133
- id: set-versions
133134
run: |
134-
# Create a Python script to read and parse versions
135-
cat > get_versions.py << 'EOF'
136-
import tomli
137-
import re
138-
import json
139-
140-
# Read pyproject.toml
141-
with open("pyproject.toml", "rb") as f:
142-
data = tomli.load(f)
143-
144-
# Get Python version requirement
145-
python_req = data["project"]["requires-python"]
146-
147-
# Parse min and max versions
148-
min_version = re.search(r">=(\d+\.\d+)", python_req)
149-
max_version = re.search(r"<(\d+\.\d+)", python_req)
150-
151-
python_versions = []
152-
if min_version and max_version:
153-
# Convert version strings to tuples
154-
min_v = tuple(map(int, min_version.group(1).split(".")))
155-
max_v = tuple(map(int, max_version.group(1).split(".")))
156-
157-
# Generate version list
158-
current = min_v
159-
while current < max_v:
160-
python_versions.append(".".join(map(str, current)))
161-
current = (current[0], current[1] + 1)
162-
163-
164-
# parse MPL versions
165-
mpl_req = None
166-
for d in data["project"]["dependencies"]:
167-
if d.startswith("matplotlib"):
168-
mpl_req = d
169-
break
170-
assert mpl_req is not None, "matplotlib version not found in dependencies"
171-
min_version = re.search(r">=(\d+\.\d+)", mpl_req)
172-
max_version = re.search(r"<(\d+\.\d+)", mpl_req)
173-
174-
mpl_versions = []
175-
if min_version and max_version:
176-
# Convert version strings to tuples
177-
min_v = tuple(map(int, min_version.group(1).split(".")))
178-
max_v = tuple(map(int, max_version.group(1).split(".")))
179-
180-
# Generate version list
181-
current = min_v
182-
while current < max_v:
183-
mpl_versions.append(".".join(map(str, current)))
184-
current = (current[0], current[1] + 1)
185-
186-
# If no versions found, default to 3.9
187-
if not mpl_versions:
188-
mpl_versions = ["3.9"]
189-
190-
# Create output dictionary
191-
midpoint_python = python_versions[len(python_versions) // 2]
192-
midpoint_mpl = mpl_versions[len(mpl_versions) // 2]
193-
matrix_candidates = [
194-
(python_versions[0], mpl_versions[0]), # lowest + lowest
195-
(midpoint_python, midpoint_mpl), # midpoint + midpoint
196-
(python_versions[-1], mpl_versions[-1]) # latest + latest
197-
]
198-
test_matrix = []
199-
seen = set()
200-
for py_ver, mpl_ver in matrix_candidates:
201-
key = (py_ver, mpl_ver)
202-
if key in seen:
203-
continue
204-
seen.add(key)
205-
test_matrix.append(
206-
{"python-version": py_ver, "matplotlib-version": mpl_ver}
207-
)
208-
209-
output = {
210-
"python_versions": python_versions,
211-
"matplotlib_versions": mpl_versions,
212-
"test_matrix": test_matrix,
213-
}
214-
215-
# Print as JSON
216-
print(json.dumps(output))
217-
EOF
218-
219-
# Run the script and capture output
220-
OUTPUT=$(python3 get_versions.py)
221-
PYTHON_VERSIONS=$(echo $OUTPUT | jq -r '.python_versions')
222-
MPL_VERSIONS=$(echo $OUTPUT | jq -r '.matplotlib_versions')
223-
224-
echo "Detected Python versions: ${PYTHON_VERSIONS}"
225-
echo "Detected Matplotlib versions: ${MPL_VERSIONS}"
226-
echo "Detected test matrix: $(echo $OUTPUT | jq -c '.test_matrix')"
227-
echo "python-versions=$(echo $PYTHON_VERSIONS | jq -c)" >> $GITHUB_OUTPUT
228-
echo "matplotlib-versions=$(echo $MPL_VERSIONS | jq -c)" >> $GITHUB_OUTPUT
229-
echo "test-matrix=$(echo $OUTPUT | jq -c '.test_matrix')" >> $GITHUB_OUTPUT
135+
OUTPUT=$(python tools/ci/version_support.py)
136+
echo "Detected Python versions: $(echo "$OUTPUT" | jq -c '.python_versions')"
137+
echo "Detected Matplotlib versions: $(echo "$OUTPUT" | jq -c '.matplotlib_versions')"
138+
echo "Detected test matrix: $(echo "$OUTPUT" | jq -c '.test_matrix')"
139+
python tools/ci/version_support.py --format github-output >> $GITHUB_OUTPUT
230140
231141
build:
232142
needs:

.github/workflows/test-map.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929
init-shell: bash
3030
create-args: >-
3131
--verbose
32-
python=3.11
32+
python=3.10
3333
matplotlib=3.9
3434
cache-environment: true
3535
cache-downloads: false

tools/ci/version_support.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Shared helpers for UltraPlot's supported Python/Matplotlib version contract.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
import argparse
9+
import json
10+
import re
11+
from pathlib import Path
12+
13+
try:
14+
import tomllib
15+
except ModuleNotFoundError: # pragma: no cover
16+
import tomli as tomllib
17+
18+
19+
ROOT = Path(__file__).resolve().parents[2]
20+
PYPROJECT = ROOT / "pyproject.toml"
21+
22+
23+
def load_pyproject(path: Path = PYPROJECT) -> dict:
24+
with path.open("rb") as fh:
25+
return tomllib.load(fh)
26+
27+
28+
def _expand_half_open_minor_range(spec: str) -> list[str]:
29+
min_match = re.search(r">=\s*(\d+\.\d+)", spec)
30+
max_match = re.search(r"<\s*(\d+\.\d+)", spec)
31+
if min_match is None or max_match is None:
32+
return []
33+
major_min, minor_min = map(int, min_match.group(1).split("."))
34+
major_max, minor_max = map(int, max_match.group(1).split("."))
35+
versions = []
36+
major, minor = major_min, minor_min
37+
while (major, minor) < (major_max, minor_max):
38+
versions.append(f"{major}.{minor}")
39+
minor += 1
40+
return versions
41+
42+
43+
def supported_python_versions(pyproject: dict | None = None) -> list[str]:
44+
pyproject = pyproject or load_pyproject()
45+
return _expand_half_open_minor_range(pyproject["project"]["requires-python"])
46+
47+
48+
def supported_matplotlib_versions(pyproject: dict | None = None) -> list[str]:
49+
pyproject = pyproject or load_pyproject()
50+
for dep in pyproject["project"]["dependencies"]:
51+
if dep.startswith("matplotlib"):
52+
return _expand_half_open_minor_range(dep)
53+
raise AssertionError("matplotlib dependency not found in pyproject.toml")
54+
55+
56+
def supported_python_classifiers(pyproject: dict | None = None) -> list[str]:
57+
pyproject = pyproject or load_pyproject()
58+
prefix = "Programming Language :: Python :: "
59+
versions = []
60+
for classifier in pyproject["project"]["classifiers"]:
61+
if classifier.startswith(prefix):
62+
tail = classifier.removeprefix(prefix)
63+
if re.fullmatch(r"\d+\.\d+", tail):
64+
versions.append(tail)
65+
return versions
66+
67+
68+
def build_core_test_matrix(
69+
python_versions: list[str], matplotlib_versions: list[str]
70+
) -> list[dict[str, str]]:
71+
midpoint_python = python_versions[len(python_versions) // 2]
72+
midpoint_mpl = matplotlib_versions[len(matplotlib_versions) // 2]
73+
candidates = [
74+
(python_versions[0], matplotlib_versions[0]),
75+
(midpoint_python, midpoint_mpl),
76+
(python_versions[-1], matplotlib_versions[-1]),
77+
]
78+
matrix = []
79+
seen = set()
80+
for py_ver, mpl_ver in candidates:
81+
key = (py_ver, mpl_ver)
82+
if key in seen:
83+
continue
84+
seen.add(key)
85+
matrix.append({"python-version": py_ver, "matplotlib-version": mpl_ver})
86+
return matrix
87+
88+
89+
def build_version_payload(pyproject: dict | None = None) -> dict:
90+
pyproject = pyproject or load_pyproject()
91+
python_versions = supported_python_versions(pyproject)
92+
matplotlib_versions = supported_matplotlib_versions(pyproject)
93+
return {
94+
"python_versions": python_versions,
95+
"matplotlib_versions": matplotlib_versions,
96+
"test_matrix": build_core_test_matrix(python_versions, matplotlib_versions),
97+
}
98+
99+
100+
def _emit_github_output(payload: dict) -> str:
101+
return "\n".join(
102+
(
103+
f"python-versions={json.dumps(payload['python_versions'], separators=(',', ':'))}",
104+
f"matplotlib-versions={json.dumps(payload['matplotlib_versions'], separators=(',', ':'))}",
105+
f"test-matrix={json.dumps(payload['test_matrix'], separators=(',', ':'))}",
106+
)
107+
)
108+
109+
110+
def main() -> int:
111+
parser = argparse.ArgumentParser()
112+
parser.add_argument(
113+
"--format",
114+
choices=("json", "github-output"),
115+
default="json",
116+
)
117+
args = parser.parse_args()
118+
119+
payload = build_version_payload()
120+
if args.format == "github-output":
121+
print(_emit_github_output(payload))
122+
else:
123+
print(json.dumps(payload))
124+
return 0
125+
126+
127+
if __name__ == "__main__": # pragma: no cover
128+
raise SystemExit(main())
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from __future__ import annotations
2+
3+
import importlib.util
4+
import re
5+
from pathlib import Path
6+
7+
8+
ROOT = Path(__file__).resolve().parents[2]
9+
PYPROJECT = ROOT / "pyproject.toml"
10+
MAIN_WORKFLOW = ROOT / ".github" / "workflows" / "main.yml"
11+
TEST_MAP_WORKFLOW = ROOT / ".github" / "workflows" / "test-map.yml"
12+
PUBLISH_WORKFLOW = ROOT / ".github" / "workflows" / "publish-pypi.yml"
13+
VERSION_SUPPORT = ROOT / "tools" / "ci" / "version_support.py"
14+
15+
16+
def _load_version_support():
17+
spec = importlib.util.spec_from_file_location("version_support", VERSION_SUPPORT)
18+
module = importlib.util.module_from_spec(spec)
19+
assert spec is not None and spec.loader is not None
20+
spec.loader.exec_module(module)
21+
return module
22+
23+
24+
def test_python_classifiers_match_requires_python():
25+
version_support = _load_version_support()
26+
pyproject = version_support.load_pyproject(PYPROJECT)
27+
assert version_support.supported_python_classifiers(pyproject) == (
28+
version_support.supported_python_versions(pyproject)
29+
)
30+
31+
32+
def test_main_workflow_uses_shared_version_support_script():
33+
text = MAIN_WORKFLOW.read_text(encoding="utf-8")
34+
assert "python tools/ci/version_support.py --format github-output" in text
35+
36+
37+
def test_test_map_workflow_pins_oldest_supported_python_and_matplotlib():
38+
version_support = _load_version_support()
39+
pyproject = version_support.load_pyproject(PYPROJECT)
40+
expected_python = version_support.supported_python_versions(pyproject)[0]
41+
expected_mpl = version_support.supported_matplotlib_versions(pyproject)[0]
42+
text = TEST_MAP_WORKFLOW.read_text(encoding="utf-8")
43+
assert f"python={expected_python}" in text
44+
assert f"matplotlib={expected_mpl}" in text
45+
46+
47+
def test_publish_workflow_python_is_supported():
48+
version_support = _load_version_support()
49+
pyproject = version_support.load_pyproject(PYPROJECT)
50+
supported = set(version_support.supported_python_versions(pyproject))
51+
text = PUBLISH_WORKFLOW.read_text(encoding="utf-8")
52+
match = re.search(r'python-version:\s*"(\d+\.\d+)"', text)
53+
assert match is not None
54+
assert match.group(1) in supported

0 commit comments

Comments
 (0)