From 53560f29003c2f61de53d49a198a2c3f77720ebd Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Mar 2026 09:38:24 +1000 Subject: [PATCH 1/7] 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. --- .github/workflows/main.yml | 110 ++-------------------- .github/workflows/test-map.yml | 2 +- tools/ci/version_support.py | 128 ++++++++++++++++++++++++++ ultraplot/tests/test_core_versions.py | 54 +++++++++++ 4 files changed, 193 insertions(+), 101 deletions(-) create mode 100644 tools/ci/version_support.py create mode 100644 ultraplot/tests/test_core_versions.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f309a9513..ef05d4f47 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,6 +18,10 @@ jobs: filters: | python: - 'ultraplot/**' + - 'pyproject.toml' + - 'environment.yml' + - '.github/workflows/**' + - 'tools/ci/**' select-tests: runs-on: ubuntu-latest @@ -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 @@ -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: diff --git a/.github/workflows/test-map.yml b/.github/workflows/test-map.yml index 30b634a12..3750d7dd9 100644 --- a/.github/workflows/test-map.yml +++ b/.github/workflows/test-map.yml @@ -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 diff --git a/tools/ci/version_support.py b/tools/ci/version_support.py new file mode 100644 index 000000000..730b76c6a --- /dev/null +++ b/tools/ci/version_support.py @@ -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 + + +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()) diff --git a/ultraplot/tests/test_core_versions.py b/ultraplot/tests/test_core_versions.py new file mode 100644 index 000000000..11c5e5d9a --- /dev/null +++ b/ultraplot/tests/test_core_versions.py @@ -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 + 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 From 0d2503a90ff9f1ba7a140e3182c5938fbaa854cc Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Mar 2026 09:43:18 +1000 Subject: [PATCH 2/7] Document core version contract helpers Add concise docstrings to the shared version-support helper and the new version-contract tests so it is immediately clear which piece derives the supported ranges, which piece shapes the CI matrix, and what each test is protecting against. --- tools/ci/version_support.py | 30 +++++++++++++++++++++++++++ ultraplot/tests/test_core_versions.py | 15 ++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/tools/ci/version_support.py b/tools/ci/version_support.py index 730b76c6a..9f47e70a9 100644 --- a/tools/ci/version_support.py +++ b/tools/ci/version_support.py @@ -21,11 +21,17 @@ 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: @@ -41,11 +47,17 @@ def _expand_half_open_minor_range(spec: str) -> list[str]: 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"): @@ -54,6 +66,9 @@ def supported_matplotlib_versions(pyproject: dict | None = None) -> list[str]: 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 = [] @@ -68,6 +83,12 @@ def supported_python_classifiers(pyproject: dict | None = None) -> list[str]: 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 = [ @@ -87,6 +108,9 @@ def build_core_test_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) @@ -98,6 +122,9 @@ def build_version_payload(pyproject: dict | None = None) -> dict: 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=(',', ':'))}", @@ -108,6 +135,9 @@ def _emit_github_output(payload: dict) -> str: def main() -> int: + """ + CLI entry point used by GitHub Actions and local verification. + """ parser = argparse.ArgumentParser() parser.add_argument( "--format", diff --git a/ultraplot/tests/test_core_versions.py b/ultraplot/tests/test_core_versions.py index 11c5e5d9a..f85fd6a65 100644 --- a/ultraplot/tests/test_core_versions.py +++ b/ultraplot/tests/test_core_versions.py @@ -14,6 +14,9 @@ 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 @@ -22,6 +25,9 @@ def _load_version_support(): 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) == ( @@ -30,11 +36,17 @@ def test_python_classifiers_match_requires_python(): 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] @@ -45,6 +57,9 @@ def test_test_map_workflow_pins_oldest_supported_python_and_matplotlib(): 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)) From 2467ae97fb82e527234a7f26f15e8f4c4cae9a39 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:45:23 +0000 Subject: [PATCH 3/7] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- ultraplot/tests/test_core_versions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/ultraplot/tests/test_core_versions.py b/ultraplot/tests/test_core_versions.py index f85fd6a65..3dd79ce0e 100644 --- a/ultraplot/tests/test_core_versions.py +++ b/ultraplot/tests/test_core_versions.py @@ -4,7 +4,6 @@ import re from pathlib import Path - ROOT = Path(__file__).resolve().parents[2] PYPROJECT = ROOT / "pyproject.toml" MAIN_WORKFLOW = ROOT / ".github" / "workflows" / "main.yml" From ea06e53ec9cbe1fc69a188799d126a5158c3e011 Mon Sep 17 00:00:00 2001 From: Casper van Elteren Date: Thu, 12 Mar 2026 10:13:07 +1000 Subject: [PATCH 4/7] Update ultraplot/tests/test_core_versions.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ultraplot/tests/test_core_versions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ultraplot/tests/test_core_versions.py b/ultraplot/tests/test_core_versions.py index 3dd79ce0e..352c71d8c 100644 --- a/ultraplot/tests/test_core_versions.py +++ b/ultraplot/tests/test_core_versions.py @@ -17,8 +17,9 @@ 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) + if spec is None or spec.loader is None: + raise ImportError(f"Could not load 'version_support' module from {VERSION_SUPPORT}") module = importlib.util.module_from_spec(spec) - assert spec is not None and spec.loader is not None spec.loader.exec_module(module) return module From d769ee1d2459564618eeb1b55632b594dedd4d3d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Mar 2026 10:29:15 +1000 Subject: [PATCH 5/7] Make core version support explicit Stop inferring supported Python and Matplotlib minors from half-open ranges alone, because that breaks across major-version upgrades. Define the supported core versions explicitly in pyproject, validate them against the declared bounds, reuse the shared helper from noxfile, and add regression coverage for a future 3.x to 4.x Matplotlib transition. --- noxfile.py | 55 +++++++++---------- pyproject.toml | 4 ++ tools/ci/version_support.py | 76 ++++++++++++++++++++++++++- ultraplot/tests/test_core_versions.py | 71 ++++++++++++++++++++++++- 4 files changed, 173 insertions(+), 33 deletions(-) diff --git a/noxfile.py b/noxfile.py index 2c48cf2e8..4eadfa8ea 100644 --- a/noxfile.py +++ b/noxfile.py @@ -2,54 +2,49 @@ import json import os -import re import shlex import shutil import tempfile +import importlib.util from pathlib import Path import nox PROJECT_ROOT = Path(__file__).parent PYPROJECT_PATH = PROJECT_ROOT / "pyproject.toml" +VERSION_SUPPORT_PATH = PROJECT_ROOT / "tools" / "ci" / "version_support.py" nox.options.reuse_existing_virtualenvs = True nox.options.sessions = ["tests"] -def _load_pyproject() -> dict: - try: - import tomllib - except ImportError: # pragma: no cover - py<3.11 - import tomli as tomllib - with PYPROJECT_PATH.open("rb") as f: - return tomllib.load(f) - - -def _version_range(requirement: str) -> list[str]: - min_match = re.search(r">=(\d+\.\d+)", requirement) - max_match = re.search(r"<(\d+\.\d+)", requirement) - if not (min_match and max_match): - return [] - min_v = tuple(map(int, min_match.group(1).split("."))) - max_v = tuple(map(int, max_match.group(1).split("."))) - versions = [] - current = min_v - while current < max_v: - versions.append(".".join(map(str, current))) - current = (current[0], current[1] + 1) - return versions +def _load_version_support(): + """ + Import the shared version-support helper from the repo checkout. + """ + spec = importlib.util.spec_from_file_location( + "version_support", + VERSION_SUPPORT_PATH, + ) + if spec is None or spec.loader is None: + raise ImportError( + f"Could not load 'version_support' module from {VERSION_SUPPORT_PATH}" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module def _matrix_versions() -> tuple[list[str], list[str]]: - data = _load_pyproject() - python_req = data["project"]["requires-python"] - py_versions = _version_range(python_req) - mpl_req = next( - dep for dep in data["project"]["dependencies"] if dep.startswith("matplotlib") + """ + Derive the supported Python/Matplotlib test matrix from the shared helper. + """ + version_support = _load_version_support() + data = version_support.load_pyproject(PYPROJECT_PATH) + return ( + version_support.supported_python_versions(data), + version_support.supported_matplotlib_versions(data), ) - mpl_versions = _version_range(mpl_req) or ["3.9"] - return py_versions, mpl_versions PYTHON_VERSIONS, MPL_VERSIONS = _matrix_versions() diff --git a/pyproject.toml b/pyproject.toml index 19c424fe1..1f85fd3b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,10 @@ include-package-data = true write_to = "ultraplot/_version.py" write_to_template = "__version__ = '{version}'\n" +[tool.ultraplot.core_versions] +python = ["3.10", "3.11", "3.12", "3.13", "3.14"] +matplotlib = ["3.9", "3.10"] + [tool.ruff] ignore = ["I001", "I002", "I003", "I004"] diff --git a/tools/ci/version_support.py b/tools/ci/version_support.py index 9f47e70a9..0ed3b20a3 100644 --- a/tools/ci/version_support.py +++ b/tools/ci/version_support.py @@ -30,7 +30,11 @@ def load_pyproject(path: Path = PYPROJECT) -> dict: def _expand_half_open_minor_range(spec: str) -> list[str]: """ - Expand constraints like ``>=3.10,<3.15`` into minor-version strings. + Expand same-major constraints like ``>=3.10,<3.15`` into minor versions. + + This fallback is only safe when the lower and upper bounds are within the + same major series. Once support crosses a major boundary, the project + should declare the supported minors explicitly in ``tool.ultraplot``. """ min_match = re.search(r">=\s*(\d+\.\d+)", spec) max_match = re.search(r"<\s*(\d+\.\d+)", spec) @@ -38,6 +42,11 @@ def _expand_half_open_minor_range(spec: str) -> list[str]: return [] major_min, minor_min = map(int, min_match.group(1).split(".")) major_max, minor_max = map(int, max_match.group(1).split(".")) + if major_min != major_max: + raise ValueError( + f"Cannot infer supported minor versions from cross-major range {spec!r}. " + "Declare explicit versions in [tool.ultraplot.core_versions]." + ) versions = [] major, minor = major_min, minor_min while (major, minor) < (major_max, minor_max): @@ -46,12 +55,68 @@ def _expand_half_open_minor_range(spec: str) -> list[str]: return versions +def _configured_core_versions(pyproject: dict, key: str) -> list[str]: + """ + Return explicitly configured core versions, or an empty list if omitted. + """ + return list( + pyproject.get("tool", {}) + .get("ultraplot", {}) + .get("core_versions", {}) + .get(key, ()) + ) + + +def _parse_half_open_minor_bounds(spec: str) -> tuple[tuple[int, int], tuple[int, int]]: + """ + Parse ``>=X.Y,=\s*(\d+\.\d+)", spec) + max_match = re.search(r"<\s*(\d+\.\d+)", spec) + if min_match is None or max_match is None: + raise ValueError(f"Could not parse half-open minor range {spec!r}.") + min_version = tuple(map(int, min_match.group(1).split("."))) + max_version = tuple(map(int, max_match.group(1).split("."))) + return min_version, max_version + + +def version_satisfies_half_open_minor_range(version: str, spec: str) -> bool: + """ + Return whether a ``major.minor`` version falls within a ``>=,<`` range. + """ + current = tuple(map(int, version.split("."))) + minimum, maximum = _parse_half_open_minor_bounds(spec) + return minimum <= current < maximum + + +def _validate_versions_against_spec( + versions: list[str], spec: str, *, label: str +) -> list[str]: + """ + Ensure explicitly configured versions remain inside the declared bounds. + """ + invalid = [ + version + for version in versions + if not version_satisfies_half_open_minor_range(version, spec) + ] + if invalid: + raise ValueError( + f"Configured {label} versions {invalid!r} fall outside declared range {spec!r}." + ) + return versions + + 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"]) + configured = _configured_core_versions(pyproject, "python") + spec = pyproject["project"]["requires-python"] + if configured: + return _validate_versions_against_spec(configured, spec, label="python") + return _expand_half_open_minor_range(spec) def supported_matplotlib_versions(pyproject: dict | None = None) -> list[str]: @@ -59,8 +124,15 @@ def supported_matplotlib_versions(pyproject: dict | None = None) -> list[str]: Return the supported Matplotlib minors derived from dependencies. """ pyproject = pyproject or load_pyproject() + configured = _configured_core_versions(pyproject, "matplotlib") for dep in pyproject["project"]["dependencies"]: if dep.startswith("matplotlib"): + if configured: + return _validate_versions_against_spec( + configured, + dep, + label="matplotlib", + ) return _expand_half_open_minor_range(dep) raise AssertionError("matplotlib dependency not found in pyproject.toml") diff --git a/ultraplot/tests/test_core_versions.py b/ultraplot/tests/test_core_versions.py index 352c71d8c..ad792a994 100644 --- a/ultraplot/tests/test_core_versions.py +++ b/ultraplot/tests/test_core_versions.py @@ -6,6 +6,7 @@ ROOT = Path(__file__).resolve().parents[2] PYPROJECT = ROOT / "pyproject.toml" +NOXFILE = ROOT / "noxfile.py" MAIN_WORKFLOW = ROOT / ".github" / "workflows" / "main.yml" TEST_MAP_WORKFLOW = ROOT / ".github" / "workflows" / "test-map.yml" PUBLISH_WORKFLOW = ROOT / ".github" / "workflows" / "publish-pypi.yml" @@ -18,7 +19,9 @@ def _load_version_support(): """ spec = importlib.util.spec_from_file_location("version_support", VERSION_SUPPORT) if spec is None or spec.loader is None: - raise ImportError(f"Could not load 'version_support' module from {VERSION_SUPPORT}") + raise ImportError( + f"Could not load 'version_support' module from {VERSION_SUPPORT}" + ) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module @@ -35,6 +38,62 @@ def test_python_classifiers_match_requires_python(): ) +def test_explicit_core_versions_stay_within_declared_bounds(): + """ + Explicitly configured core versions should stay inside the declared ranges. + """ + version_support = _load_version_support() + pyproject = version_support.load_pyproject(PYPROJECT) + python_spec = pyproject["project"]["requires-python"] + matplotlib_spec = next( + dep + for dep in pyproject["project"]["dependencies"] + if dep.startswith("matplotlib") + ) + assert all( + version_support.version_satisfies_half_open_minor_range(version, python_spec) + for version in version_support.supported_python_versions(pyproject) + ) + assert all( + version_support.version_satisfies_half_open_minor_range( + version, + matplotlib_spec, + ) + for version in version_support.supported_matplotlib_versions(pyproject) + ) + + +def test_explicit_cross_major_matplotlib_versions_are_supported(tmp_path): + """ + Explicit core-version lists should support future major-version upgrades. + """ + version_support = _load_version_support() + pyproject_path = tmp_path / "pyproject.toml" + pyproject_path.write_text( + """ +[project] +requires-python = ">=3.12,<3.15" +classifiers = [ + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = ["matplotlib>=3.10,<4.2"] + +[tool.ultraplot.core_versions] +python = ["3.12", "3.13", "3.14"] +matplotlib = ["3.10", "4.0", "4.1"] +""".strip(), + encoding="utf-8", + ) + pyproject = version_support.load_pyproject(pyproject_path) + assert version_support.supported_matplotlib_versions(pyproject) == [ + "3.10", + "4.0", + "4.1", + ] + + def test_main_workflow_uses_shared_version_support_script(): """ The matrix workflow should consume the shared version helper, not reparse inline. @@ -43,6 +102,16 @@ def test_main_workflow_uses_shared_version_support_script(): assert "python tools/ci/version_support.py --format github-output" in text +def test_noxfile_uses_shared_version_support_module(): + """ + Local test matrix generation should reuse the shared version helper. + """ + text = NOXFILE.read_text(encoding="utf-8") + assert "VERSION_SUPPORT_PATH" in text + assert "supported_python_versions" in text + assert "supported_matplotlib_versions" in text + + def test_test_map_workflow_pins_oldest_supported_python_and_matplotlib(): """ The cache-building workflow should exercise the lowest supported core pair. From 2ee34b7916ab46cc11229ca46b8b71f0bf145d5c Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Mar 2026 10:46:06 +1000 Subject: [PATCH 6/7] Handle cross-major version filtering Use direct range filtering for candidate core versions so version checks work cleanly across major-version boundaries. Keep same-major arithmetic expansion only as a fallback, and add a regression test covering a 3.x to 4.x Matplotlib transition. --- tools/ci/version_support.py | 27 +++++++++++++++++++++------ ultraplot/tests/test_core_versions.py | 12 ++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/tools/ci/version_support.py b/tools/ci/version_support.py index 0ed3b20a3..af6147fcd 100644 --- a/tools/ci/version_support.py +++ b/tools/ci/version_support.py @@ -89,22 +89,37 @@ def version_satisfies_half_open_minor_range(version: str, spec: str) -> bool: return minimum <= current < maximum -def _validate_versions_against_spec( - versions: list[str], spec: str, *, label: str +def select_versions_within_half_open_minor_range( + versions: list[str], + spec: str, ) -> list[str]: """ - Ensure explicitly configured versions remain inside the declared bounds. + Return the candidate versions that fall within the declared range. + + This is the cross-major-safe path because it compares each candidate + directly against the range bounds instead of trying to infer every + intermediate minor from arithmetic alone. """ - invalid = [ + return [ version for version in versions - if not version_satisfies_half_open_minor_range(version, spec) + if version_satisfies_half_open_minor_range(version, spec) ] + + +def _validate_versions_against_spec( + versions: list[str], spec: str, *, label: str +) -> list[str]: + """ + Ensure explicitly configured versions remain inside the declared bounds. + """ + valid = select_versions_within_half_open_minor_range(versions, spec) + invalid = [version for version in versions if version not in valid] if invalid: raise ValueError( f"Configured {label} versions {invalid!r} fall outside declared range {spec!r}." ) - return versions + return valid def supported_python_versions(pyproject: dict | None = None) -> list[str]: diff --git a/ultraplot/tests/test_core_versions.py b/ultraplot/tests/test_core_versions.py index ad792a994..2228b94ef 100644 --- a/ultraplot/tests/test_core_versions.py +++ b/ultraplot/tests/test_core_versions.py @@ -94,6 +94,18 @@ def test_explicit_cross_major_matplotlib_versions_are_supported(tmp_path): ] +def test_cross_major_range_filter_selects_valid_versions(): + """ + Range filtering should work across major boundaries once candidate minors exist. + """ + version_support = _load_version_support() + versions = ["3.9", "3.10", "3.11", "4.0", "4.1", "4.2"] + assert version_support.select_versions_within_half_open_minor_range( + versions, + ">=3.10,<4.2", + ) == ["3.10", "3.11", "4.0", "4.1"] + + def test_main_workflow_uses_shared_version_support_script(): """ The matrix workflow should consume the shared version helper, not reparse inline. From f42d2d5a3275744a6def6c9be8d656494acb356e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 12 Mar 2026 10:49:55 +1000 Subject: [PATCH 7/7] Add pip Dependabot updates Teach Dependabot to monitor the project Python dependencies in pyproject.toml so Matplotlib and related package bumps are proposed automatically alongside the existing GitHub Actions updates. --- .github/dependabot.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5f454fdfb..988f99c44 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,5 +1,13 @@ version: 2 updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + groups: + python-dependencies: + patterns: + - "*" - package-ecosystem: "github-actions" directory: "/" schedule: