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: 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/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 new file mode 100644 index 000000000..af6147fcd --- /dev/null +++ b/tools/ci/version_support.py @@ -0,0 +1,245 @@ +#!/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 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) + 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(".")) + 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): + versions.append(f"{major}.{minor}") + minor += 1 + 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 select_versions_within_half_open_minor_range( + versions: list[str], + spec: str, +) -> list[str]: + """ + 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. + """ + return [ + version + for version in versions + 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 valid + + +def supported_python_versions(pyproject: dict | None = None) -> list[str]: + """ + Return the supported Python minors derived from ``requires-python``. + """ + pyproject = pyproject or load_pyproject() + 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]: + """ + 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") + + +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()) diff --git a/ultraplot/tests/test_core_versions.py b/ultraplot/tests/test_core_versions.py new file mode 100644 index 000000000..2228b94ef --- /dev/null +++ b/ultraplot/tests/test_core_versions.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import importlib.util +import re +from pathlib import Path + +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" +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) + 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) + 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_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_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. + """ + text = MAIN_WORKFLOW.read_text(encoding="utf-8") + 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. + """ + 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