Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
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
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
158 changes: 158 additions & 0 deletions tools/ci/version_support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
#!/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:
"""
Load the project metadata used to define the supported version contract.
"""
with path.open("rb") as fh:
return tomllib.load(fh)


def _expand_half_open_minor_range(spec: str) -> list[str]:
"""
Expand constraints like ``>=3.10,<3.15`` into minor-version strings.
"""
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]:
"""
Return the supported Python minors derived from ``requires-python``.
"""
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]:
"""
Return the supported Matplotlib minors derived from dependencies.
"""
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]:
"""
Extract the explicit Python version classifiers from ``pyproject.toml``.
"""
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]]:
"""
Build the representative CI matrix from the supported version bounds.

We intentionally sample the oldest, midpoint, and newest supported
Python/Matplotlib combinations instead of exhaustively testing every pair.
"""
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:
"""
Bundle the version contract into the shape expected by CI and tests.
"""
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:
"""
Format the derived version payload for ``$GITHUB_OUTPUT`` consumption.
"""
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:
"""
CLI entry point used by GitHub Actions and local verification.
"""
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())
68 changes: 68 additions & 0 deletions ultraplot/tests/test_core_versions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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():
"""
Import the shared version helper directly from the repo checkout.
"""
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():
"""
Supported Python classifiers should mirror the declared version range.
"""
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():
"""
The matrix workflow should consume the shared version helper, not reparse inline.
"""
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():
"""
The cache-building workflow should exercise the lowest supported core pair.
"""
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():
"""
Package builds should run on a Python version that UltraPlot declares support for.
"""
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