Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
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.
  • Loading branch information
cvanelteren committed Mar 11, 2026
commit 53560f29003c2f61de53d49a198a2c3f77720ebd
110 changes: 10 additions & 100 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ jobs:
filters: |
python:
- 'ultraplot/**'
- 'pyproject.toml'
- 'environment.yml'
- '.github/workflows/**'
- 'tools/ci/**'

select-tests:
runs-on: ubuntu-latest
Expand Down Expand Up @@ -52,7 +56,7 @@ jobs:
init-shell: bash
create-args: >-
--verbose
python=3.11
python=3.10
matplotlib=3.9
cache-environment: true
cache-downloads: false
Expand Down Expand Up @@ -126,107 +130,13 @@ jobs:
with:
python-version: "3.11"

- name: Install dependencies
run: pip install tomli

- id: set-versions
run: |
# Create a Python script to read and parse versions
cat > get_versions.py << 'EOF'
import tomli
import re
import json

# Read pyproject.toml
with open("pyproject.toml", "rb") as f:
data = tomli.load(f)

# Get Python version requirement
python_req = data["project"]["requires-python"]

# Parse min and max versions
min_version = re.search(r">=(\d+\.\d+)", python_req)
max_version = re.search(r"<(\d+\.\d+)", python_req)

python_versions = []
if min_version and max_version:
# Convert version strings to tuples
min_v = tuple(map(int, min_version.group(1).split(".")))
max_v = tuple(map(int, max_version.group(1).split(".")))

# Generate version list
current = min_v
while current < max_v:
python_versions.append(".".join(map(str, current)))
current = (current[0], current[1] + 1)


# parse MPL versions
mpl_req = None
for d in data["project"]["dependencies"]:
if d.startswith("matplotlib"):
mpl_req = d
break
assert mpl_req is not None, "matplotlib version not found in dependencies"
min_version = re.search(r">=(\d+\.\d+)", mpl_req)
max_version = re.search(r"<(\d+\.\d+)", mpl_req)

mpl_versions = []
if min_version and max_version:
# Convert version strings to tuples
min_v = tuple(map(int, min_version.group(1).split(".")))
max_v = tuple(map(int, max_version.group(1).split(".")))

# Generate version list
current = min_v
while current < max_v:
mpl_versions.append(".".join(map(str, current)))
current = (current[0], current[1] + 1)

# If no versions found, default to 3.9
if not mpl_versions:
mpl_versions = ["3.9"]

# Create output dictionary
midpoint_python = python_versions[len(python_versions) // 2]
midpoint_mpl = mpl_versions[len(mpl_versions) // 2]
matrix_candidates = [
(python_versions[0], mpl_versions[0]), # lowest + lowest
(midpoint_python, midpoint_mpl), # midpoint + midpoint
(python_versions[-1], mpl_versions[-1]) # latest + latest
]
test_matrix = []
seen = set()
for py_ver, mpl_ver in matrix_candidates:
key = (py_ver, mpl_ver)
if key in seen:
continue
seen.add(key)
test_matrix.append(
{"python-version": py_ver, "matplotlib-version": mpl_ver}
)

output = {
"python_versions": python_versions,
"matplotlib_versions": mpl_versions,
"test_matrix": test_matrix,
}

# Print as JSON
print(json.dumps(output))
EOF

# Run the script and capture output
OUTPUT=$(python3 get_versions.py)
PYTHON_VERSIONS=$(echo $OUTPUT | jq -r '.python_versions')
MPL_VERSIONS=$(echo $OUTPUT | jq -r '.matplotlib_versions')

echo "Detected Python versions: ${PYTHON_VERSIONS}"
echo "Detected Matplotlib versions: ${MPL_VERSIONS}"
echo "Detected test matrix: $(echo $OUTPUT | jq -c '.test_matrix')"
echo "python-versions=$(echo $PYTHON_VERSIONS | jq -c)" >> $GITHUB_OUTPUT
echo "matplotlib-versions=$(echo $MPL_VERSIONS | jq -c)" >> $GITHUB_OUTPUT
echo "test-matrix=$(echo $OUTPUT | jq -c '.test_matrix')" >> $GITHUB_OUTPUT
OUTPUT=$(python tools/ci/version_support.py)
echo "Detected Python versions: $(echo "$OUTPUT" | jq -c '.python_versions')"
echo "Detected Matplotlib versions: $(echo "$OUTPUT" | jq -c '.matplotlib_versions')"
echo "Detected test matrix: $(echo "$OUTPUT" | jq -c '.test_matrix')"
python tools/ci/version_support.py --format github-output >> $GITHUB_OUTPUT

build:
needs:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test-map.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
init-shell: bash
create-args: >-
--verbose
python=3.11
python=3.10
matplotlib=3.9
cache-environment: true
cache-downloads: false
Expand Down
128 changes: 128 additions & 0 deletions tools/ci/version_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""
Shared helpers for UltraPlot's supported Python/Matplotlib version contract.
"""

from __future__ import annotations

import argparse
import json
import re
from pathlib import Path

try:
import tomllib
except ModuleNotFoundError: # pragma: no cover
import tomli as tomllib


ROOT = Path(__file__).resolve().parents[2]
PYPROJECT = ROOT / "pyproject.toml"


def load_pyproject(path: Path = PYPROJECT) -> dict:
with path.open("rb") as fh:
return tomllib.load(fh)


def _expand_half_open_minor_range(spec: str) -> list[str]:
min_match = re.search(r">=\s*(\d+\.\d+)", spec)
max_match = re.search(r"<\s*(\d+\.\d+)", spec)
if min_match is None or max_match is None:
return []
major_min, minor_min = map(int, min_match.group(1).split("."))
major_max, minor_max = map(int, max_match.group(1).split("."))
versions = []
major, minor = major_min, minor_min
while (major, minor) < (major_max, minor_max):
versions.append(f"{major}.{minor}")
minor += 1
return versions
Comment thread
cvanelteren marked this conversation as resolved.


def supported_python_versions(pyproject: dict | None = None) -> list[str]:
pyproject = pyproject or load_pyproject()
return _expand_half_open_minor_range(pyproject["project"]["requires-python"])


def supported_matplotlib_versions(pyproject: dict | None = None) -> list[str]:
pyproject = pyproject or load_pyproject()
for dep in pyproject["project"]["dependencies"]:
if dep.startswith("matplotlib"):
return _expand_half_open_minor_range(dep)
raise AssertionError("matplotlib dependency not found in pyproject.toml")


def supported_python_classifiers(pyproject: dict | None = None) -> list[str]:
pyproject = pyproject or load_pyproject()
prefix = "Programming Language :: Python :: "
versions = []
for classifier in pyproject["project"]["classifiers"]:
if classifier.startswith(prefix):
tail = classifier.removeprefix(prefix)
if re.fullmatch(r"\d+\.\d+", tail):
versions.append(tail)
return versions


def build_core_test_matrix(
python_versions: list[str], matplotlib_versions: list[str]
) -> list[dict[str, str]]:
midpoint_python = python_versions[len(python_versions) // 2]
midpoint_mpl = matplotlib_versions[len(matplotlib_versions) // 2]
candidates = [
(python_versions[0], matplotlib_versions[0]),
(midpoint_python, midpoint_mpl),
(python_versions[-1], matplotlib_versions[-1]),
]
matrix = []
seen = set()
for py_ver, mpl_ver in candidates:
key = (py_ver, mpl_ver)
if key in seen:
continue
seen.add(key)
matrix.append({"python-version": py_ver, "matplotlib-version": mpl_ver})
return matrix


def build_version_payload(pyproject: dict | None = None) -> dict:
pyproject = pyproject or load_pyproject()
python_versions = supported_python_versions(pyproject)
matplotlib_versions = supported_matplotlib_versions(pyproject)
return {
"python_versions": python_versions,
"matplotlib_versions": matplotlib_versions,
"test_matrix": build_core_test_matrix(python_versions, matplotlib_versions),
}


def _emit_github_output(payload: dict) -> str:
return "\n".join(
(
f"python-versions={json.dumps(payload['python_versions'], separators=(',', ':'))}",
f"matplotlib-versions={json.dumps(payload['matplotlib_versions'], separators=(',', ':'))}",
f"test-matrix={json.dumps(payload['test_matrix'], separators=(',', ':'))}",
)
)


def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--format",
choices=("json", "github-output"),
default="json",
)
args = parser.parse_args()

payload = build_version_payload()
if args.format == "github-output":
print(_emit_github_output(payload))
else:
print(json.dumps(payload))
return 0


if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
54 changes: 54 additions & 0 deletions ultraplot/tests/test_core_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from __future__ import annotations

import importlib.util
import re
from pathlib import Path


ROOT = Path(__file__).resolve().parents[2]
PYPROJECT = ROOT / "pyproject.toml"
MAIN_WORKFLOW = ROOT / ".github" / "workflows" / "main.yml"
TEST_MAP_WORKFLOW = ROOT / ".github" / "workflows" / "test-map.yml"
PUBLISH_WORKFLOW = ROOT / ".github" / "workflows" / "publish-pypi.yml"
VERSION_SUPPORT = ROOT / "tools" / "ci" / "version_support.py"


def _load_version_support():
spec = importlib.util.spec_from_file_location("version_support", VERSION_SUPPORT)
module = importlib.util.module_from_spec(spec)
assert spec is not None and spec.loader is not None
Comment thread
cvanelteren marked this conversation as resolved.
Outdated
spec.loader.exec_module(module)
return module


def test_python_classifiers_match_requires_python():
version_support = _load_version_support()
pyproject = version_support.load_pyproject(PYPROJECT)
assert version_support.supported_python_classifiers(pyproject) == (
version_support.supported_python_versions(pyproject)
)


def test_main_workflow_uses_shared_version_support_script():
text = MAIN_WORKFLOW.read_text(encoding="utf-8")
assert "python tools/ci/version_support.py --format github-output" in text


def test_test_map_workflow_pins_oldest_supported_python_and_matplotlib():
version_support = _load_version_support()
pyproject = version_support.load_pyproject(PYPROJECT)
expected_python = version_support.supported_python_versions(pyproject)[0]
expected_mpl = version_support.supported_matplotlib_versions(pyproject)[0]
text = TEST_MAP_WORKFLOW.read_text(encoding="utf-8")
assert f"python={expected_python}" in text
assert f"matplotlib={expected_mpl}" in text


def test_publish_workflow_python_is_supported():
version_support = _load_version_support()
pyproject = version_support.load_pyproject(PYPROJECT)
supported = set(version_support.supported_python_versions(pyproject))
text = PUBLISH_WORKFLOW.read_text(encoding="utf-8")
match = re.search(r'python-version:\s*"(\d+\.\d+)"', text)
assert match is not None
assert match.group(1) in supported
Loading