diff --git a/.github/workflows/test_action.yaml b/.github/workflows/test_action.yaml index 3ec9fb6..1356382 100644 --- a/.github/workflows/test_action.yaml +++ b/.github/workflows/test_action.yaml @@ -54,3 +54,29 @@ jobs: else grep -q '^+.*numpy' changes.diff fi + + constraints: + runs-on: ubuntu-latest + name: "Run action in constraints mode" + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Write constraints using local action + id: spec0 + uses: ./ + with: + mode: constraints + packages: numpy scipy pandas python + schedule_path: tests/test_data/test_schedule.json + reference_date: "2025-04-01" + - name: Check constraints file and outputs + env: + CONSTRAINTS_FILE: ${{ steps.spec0.outputs.constraints_file }} + VERSIONS: ${{ steps.spec0.outputs.versions }} + PYTHON_VERSION: ${{ steps.spec0.outputs.python_version }} + run: | + printf 'numpy==1.26.0\nscipy==1.12.0\npandas==2.1.0\n' > expected.txt + diff expected.txt "$CONSTRAINTS_FILE" + test "$PYTHON_VERSION" = "3.11" + echo "$VERSIONS" | python -c 'import json, sys; assert json.load(sys.stdin)["numpy"] == "1.26.0"' + git diff --exit-code diff --git a/action.yaml b/action.yaml index 17c3eca..2f55c19 100644 --- a/action.yaml +++ b/action.yaml @@ -46,12 +46,40 @@ inputs: description: "If set, compute SPEC 0 core-package floors from PyPI using this many years of support (e.g., 3). Python still follows the supplied schedule; exclusions take precedence." required: false default: "" + mode: + description: "'update' (default) updates the lower bounds in the project file and can open a PR. 'constraints' only writes a constraints file with exact SPEC 0 minimum versions, to install and test the oldest supported versions in CI." + required: false + default: "update" + packages: + description: "constraints mode: comma- or whitespace-separated packages to pin, e.g. 'numpy scipy pandas'. Include python to get its minimum version in the outputs." + required: false + default: "" + constraints_file: + description: "constraints mode: path of the constraints file to write, relative to the workspace." + required: false + default: "constraints-spec0.txt" + reference_date: + description: "constraints mode: date (YYYY-MM-DD) to compute the floors for. Defaults to today." + required: false + default: "" +outputs: + constraints_file: + description: "constraints mode: path of the written constraints file." + value: ${{ steps.constraints.outputs.constraints_file }} + versions: + description: "constraints mode: JSON mapping of package to minimum version." + value: ${{ steps.constraints.outputs.versions }} + python_version: + description: "constraints mode: minimum Python version, if python was requested." + value: ${{ steps.constraints.outputs.python_version }} runs: using: "composite" steps: - name: Checkout code + if: ${{ inputs.mode != 'constraints' }} uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Git + if: ${{ inputs.mode != 'constraints' }} shell: bash run: | git config user.name "Scientific Python [bot]" @@ -69,6 +97,7 @@ runs: latest: true fileName: "schedule.json" - name: Run update script + if: ${{ inputs.mode != 'constraints' }} shell: bash env: PROJECT_FILE_NAME: ${{ inputs.project_file_name }} @@ -92,7 +121,29 @@ runs: UPDATE_ARGS+=(--spec0-support-years "$SPEC0_SUPPORT_YEARS") fi pixi run --manifest-path "${GITHUB_ACTION_PATH}/pyproject.toml" update-dependencies "${GITHUB_WORKSPACE}/${PROJECT_FILE_NAME}" "$SCHEDULE_PATH" --excluded-packages "$EXCLUDED_PACKAGES" "${UPDATE_ARGS[@]}" + - name: Write constraints file + if: ${{ inputs.mode == 'constraints' }} + id: constraints + shell: bash + env: + SCHEDULE_INPUT: ${{ inputs.schedule_path }} + PACKAGES: ${{ inputs.packages }} + CONSTRAINTS_FILE: ${{ inputs.constraints_file }} + REFERENCE_DATE: ${{ inputs.reference_date }} + run: | + set -e + if [ -n "$SCHEDULE_INPUT" ]; then + SCHEDULE_PATH="${GITHUB_WORKSPACE}/${SCHEDULE_INPUT}" + else + SCHEDULE_PATH="${GITHUB_WORKSPACE}/schedule.json" + fi + ARGS=(--packages "$PACKAGES" --output "${GITHUB_WORKSPACE}/${CONSTRAINTS_FILE}") + if [ -n "$REFERENCE_DATE" ]; then + ARGS+=(--reference-date "$REFERENCE_DATE") + fi + pixi run --manifest-path "${GITHUB_ACTION_PATH}/pyproject.toml" write-constraints "$SCHEDULE_PATH" "${ARGS[@]}" - name: Changes + if: ${{ inputs.mode != 'constraints' }} id: changes shell: bash env: @@ -106,7 +157,7 @@ runs: echo "changes_detected=true" >> "$GITHUB_OUTPUT" fi - name: Create Pull Request - if: ${{ fromJSON(inputs.create_pr) && fromJSON(steps.changes.outputs.changes_detected) }} + if: ${{ inputs.mode != 'constraints' && fromJSON(inputs.create_pr) && fromJSON(steps.changes.outputs.changes_detected) }} uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ inputs.token || github.token }} diff --git a/pyproject.toml b/pyproject.toml index 5cdf08b..95f8af2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ spec0-action= { path = ".", editable = true } [tool.pixi.tasks] update-dependencies = { cmd = ["python", "run_spec0_update.py"] } +write-constraints = { cmd = ["python", "run_spec0_constraints.py"] } generate-schedule = { cmd = ["python", "spec0_versions.py"] } [tool.pixi.feature.test.tasks] diff --git a/readme.md b/readme.md index 0cd09b1..9b2e926 100644 --- a/readme.md +++ b/readme.md @@ -96,6 +96,43 @@ If PyPI cannot be reached, the affected dependency stays unchanged with a warnin The CLI takes `--spec0-support-years 3` and the Python API `spec0_support_years=3`. Extras such as `xarray[io]>=2026.5.1` and environment markers are preserved when bounds change. +### Testing the oldest supported versions in CI + +With `mode: constraints` the action does not touch your project file or open a PR. +It writes a constraints file with exact pins of the SPEC 0 minimum versions, so a CI job can install and test the oldest supported combination: + +```yaml +- uses: actions/checkout@v7 +- uses: scientific-python/spec0-action@ + id: spec0 + with: + mode: constraints + packages: numpy scipy pandas scikit-learn matplotlib python +- uses: actions/setup-python@v6 + with: + python-version: ${{ steps.spec0.outputs.python_version }} +- run: | + pip install -c constraints-spec0.txt . + pytest +``` + +The pins are exact (`numpy==2.3.0`), because with `numpy>=2.3.0` the installer would pick the newest release and the minimum would never be tested. +Constraints only restrict versions; they do not install the listed packages by themselves. + +| Input | Default | Description | +| ------------------ | ----------------------- | ---------------------------------------------------------------------------------------- | +| `mode` | `update` | Set to `constraints` to only write the constraints file | +| `packages` | — | Comma- or whitespace-separated packages to pin; add `python` to get its minimum | +| `constraints_file` | `constraints-spec0.txt` | Path of the file to write, relative to the workspace | +| `reference_date` | today | Date (`YYYY-MM-DD`) to compute the floors for, for reproducible runs | +| `schedule_path` | — | Local `schedule.json`, relative to the repository root; uses the latest release if unset | + +Outputs: `constraints_file`, `versions` (JSON mapping of package to minimum version) and `python_version` (empty unless `python` is requested). +The run fails with a clear message if the schedule has no floor for a requested package on the reference date, or if the schedule data is invalid. +A local `schedule_path` needs the repository to be checked out first. + +The CLI is `python run_spec0_constraints.py schedule.json --packages "numpy scipy" --reference-date 2026-10-01`. + ## Limitations 1. The action only tightens lower bounds and leaves upper bounds untouched. diff --git a/run_spec0_constraints.py b/run_spec0_constraints.py new file mode 100644 index 0000000..cece6a1 --- /dev/null +++ b/run_spec0_constraints.py @@ -0,0 +1,80 @@ +import datetime +import json +import os +import sys +from argparse import ArgumentParser +from pathlib import Path + +from spec0_action import read_schedule +from spec0_action.constraints import ScheduleError, format_constraints, minimum_versions + + +def parse_date(value: str) -> datetime.datetime: + try: + date = datetime.date.fromisoformat(value) + except ValueError as exc: + raise ValueError( + f"reference date must look like YYYY-MM-DD, got {value!r}" + ) from exc + return datetime.datetime(date.year, date.month, date.day, tzinfo=datetime.UTC) + + +def main(argv: list[str] | None = None) -> int: + parser = ArgumentParser( + description="Write exact SPEC 0 minimum version pins to a constraints file, to test the oldest supported versions in CI.", + ) + parser.add_argument("schedule_path", help="Path to the schedule json payload.") + parser.add_argument( + "--packages", + required=True, + metavar="NAMES", + help="Comma- or whitespace-separated packages to pin, e.g. 'numpy scipy pandas'. Include python to also report its minimum version.", + ) + parser.add_argument( + "--output", + default="constraints-spec0.txt", + help="Where to write the constraints file. Defaults to 'constraints-spec0.txt'.", + ) + parser.add_argument( + "--reference-date", + default=None, + metavar="YYYY-MM-DD", + help="Date the floors are computed for. Defaults to today (UTC).", + ) + args = parser.parse_args(argv) + + schedule_path = Path(args.schedule_path) + if not schedule_path.exists(): + raise ValueError( + f"{schedule_path} was supplied as path to schedule file but it did not exist" + ) + if args.reference_date: + reference_date = parse_date(args.reference_date) + else: + reference_date = datetime.datetime.now(datetime.UTC) + + try: + versions = minimum_versions( + read_schedule(schedule_path), args.packages, reference_date + ) + except ScheduleError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + output = Path(args.output) + output.write_text(format_constraints(versions)) + mapping = {package: str(version) for package, version in versions.items()} + print(f"Wrote {output}:") + print(output.read_text(), end="") + + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a") as file: + file.write(f"constraints_file={output}\n") + file.write(f"versions={json.dumps(mapping)}\n") + file.write(f"python_version={mapping.get('python', '')}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/spec0_action/constraints.py b/spec0_action/constraints.py new file mode 100644 index 0000000..8a88ff3 --- /dev/null +++ b/spec0_action/constraints.py @@ -0,0 +1,104 @@ +"""Exact SPEC 0 minimum version pins for testing the oldest supported versions in CI.""" + +import datetime +from collections.abc import Iterable, Sequence + +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version + +from spec0_action.parsing import SupportSchedule + + +class ScheduleError(ValueError): + """The schedule is malformed or cannot provide a requested floor.""" + + +def _parse_start_date(entry: SupportSchedule, index: int) -> datetime.datetime: + try: + start = datetime.datetime.fromisoformat(entry["start_date"]) + except (KeyError, TypeError, ValueError) as exc: + raise ScheduleError( + f"schedule entry {index} has a missing or invalid start_date" + ) from exc + if start.tzinfo is None: + start = start.replace(tzinfo=datetime.UTC) + return start + + +def schedule_floors( + schedule_data: Sequence[SupportSchedule], reference_date: datetime.datetime +) -> dict[str, Version]: + """Return the minimum version of every package in the schedule on ``reference_date``. + + Each schedule entry only lists the packages whose floor changes at its start date, + so all entries up to the reference date are applied in order and later entries + overwrite earlier ones. + """ + if reference_date.tzinfo is None: + reference_date = reference_date.replace(tzinfo=datetime.UTC) + if not isinstance(schedule_data, Sequence) or isinstance(schedule_data, str): + raise ScheduleError("the schedule must be a list of entries") + + dated = [] + for index, entry in enumerate(schedule_data): + if not isinstance(entry, dict) or not isinstance(entry.get("packages"), dict): + raise ScheduleError(f"schedule entry {index} has no packages mapping") + dated.append((_parse_start_date(entry, index), index, entry)) + + floors: dict[str, Version] = {} + for start, index, entry in sorted(dated, key=lambda item: item[0]): + if start > reference_date: + break + for package, version in entry["packages"].items(): + try: + floors[canonicalize_name(package)] = Version(version) + except (InvalidVersion, TypeError) as exc: + raise ScheduleError( + f"schedule entry {index} has an invalid version {version!r} for {package}" + ) from exc + return floors + + +def parse_package_list(packages: str | Iterable[str]) -> list[str]: + """Split a comma- or whitespace-separated list and normalize the names, keeping order.""" + if isinstance(packages, str): + packages = packages.replace(",", " ").split() + names: list[str] = [] + for package in packages: + name = canonicalize_name(package, validate=True) + if name not in names: + names.append(name) + return names + + +def minimum_versions( + schedule_data: Sequence[SupportSchedule], + packages: str | Iterable[str], + reference_date: datetime.datetime, +) -> dict[str, Version]: + """Return the SPEC 0 floor of each requested package, failing if any is unknown.""" + requested = parse_package_list(packages) + if not requested: + raise ScheduleError("no packages were requested") + floors = schedule_floors(schedule_data, reference_date) + missing = [package for package in requested if package not in floors] + if missing: + raise ScheduleError( + f"the schedule has no floor on {reference_date.date()} for: {', '.join(missing)}" + ) + return {package: floors[package] for package in requested} + + +def format_constraints(versions: dict[str, Version]) -> str: + """Format exact pins for a pip/uv constraints file. + + Exact pins are needed: with ``>=`` the installer would pick the newest release and + the oldest supported version would never be tested. Python is not a package, so it + is left out here and reported separately. + """ + lines = [ + f"{package}=={version}" + for package, version in versions.items() + if package != "python" + ] + return "".join(f"{line}\n" for line in lines) diff --git a/tests/test_constraints.py b/tests/test_constraints.py new file mode 100644 index 0000000..41565d7 --- /dev/null +++ b/tests/test_constraints.py @@ -0,0 +1,188 @@ +import datetime +import json +import runpy +from pathlib import Path + +import pytest +from packaging.version import Version + +from spec0_action import read_schedule +from spec0_action.constraints import ( + ScheduleError, + format_constraints, + minimum_versions, + parse_package_list, + schedule_floors, +) + +ROOT = Path(__file__).resolve().parents[1] +main = runpy.run_path(str(ROOT / "run_spec0_constraints.py"))["main"] +TEST_SCHEDULE = ROOT / "tests" / "test_data" / "test_schedule.json" +SCHEDULE = [ + { + "start_date": "2025-01-01T00:00:00Z", + "packages": {"numpy": "2.0.0", "pandas": "2.2.0", "python": "3.12"}, + }, + { + "start_date": "2025-04-01T00:00:00Z", + "packages": {"numpy": "2.1.0", "scikit-learn": "1.5.0"}, + }, + { + "start_date": "2025-07-01T00:00:00Z", + "packages": {"Scikit_Learn": "1.6.0", "python": "3.13"}, + }, +] + + +def utc(year, month, day): + return datetime.datetime(year, month, day, tzinfo=datetime.UTC) + + +def test_floors_combine_all_entries_up_to_the_date(): + floors = schedule_floors(SCHEDULE, utc(2025, 5, 15)) + # numpy was raised in the second entry, pandas and python only appear in the first + assert floors == { + "numpy": Version("2.1.0"), + "pandas": Version("2.2.0"), + "python": Version("3.12"), + "scikit-learn": Version("1.5.0"), + } + + +def test_entry_applies_from_its_start_date(): + assert schedule_floors(SCHEDULE, utc(2025, 6, 30))["python"] == Version("3.12") + assert schedule_floors(SCHEDULE, utc(2025, 7, 1))["python"] == Version("3.13") + + +def test_unsorted_schedule_and_naive_date(): + floors = schedule_floors(list(reversed(SCHEDULE)), datetime.datetime(2025, 8, 1)) + assert floors["scikit-learn"] == Version("1.6.0") + assert floors["numpy"] == Version("2.1.0") + + +def test_nothing_applies_before_the_first_entry(): + assert schedule_floors(SCHEDULE, utc(2024, 12, 31)) == {} + + +def test_minimum_versions_keeps_requested_order(): + versions = minimum_versions(SCHEDULE, "scikit-learn, numpy", utc(2025, 8, 1)) + assert list(versions) == ["scikit-learn", "numpy"] + + +def test_missing_floor_lists_every_missing_package(): + with pytest.raises(ScheduleError, match="matplotlib, scipy"): + minimum_versions(SCHEDULE, "numpy matplotlib scipy", utc(2025, 8, 1)) + + +def test_floor_not_yet_in_effect_is_missing(): + with pytest.raises(ScheduleError, match="scikit-learn"): + minimum_versions(SCHEDULE, "scikit-learn", utc(2025, 2, 1)) + + +def test_no_packages_requested(): + with pytest.raises(ScheduleError, match="no packages"): + minimum_versions(SCHEDULE, " , ", utc(2025, 8, 1)) + + +@pytest.mark.parametrize( + ("schedule", "message"), + [ + ({"packages": {}}, "list"), + ([{"start_date": "2025-01-01"}], "no packages mapping"), + ([{"packages": {"numpy": "2.0"}}], "start_date"), + ([{"start_date": "soon", "packages": {}}], "start_date"), + ( + [{"start_date": "2025-01-01", "packages": {"numpy": "two"}}], + "invalid version", + ), + ], +) +def test_invalid_schedule(schedule, message): + with pytest.raises(ScheduleError, match=message): + schedule_floors(schedule, utc(2025, 8, 1)) + + +def test_package_list_is_normalized_and_deduplicated(): + assert parse_package_list("NumPy,scikit_learn numpy\nscipy") == [ + "numpy", + "scikit-learn", + "scipy", + ] + + +def test_constraints_are_exact_pins_without_python(): + versions = minimum_versions(SCHEDULE, "python numpy pandas", utc(2025, 8, 1)) + assert format_constraints(versions) == "numpy==2.1.0\npandas==2.2.0\n" + + +def test_cli_writes_constraints_and_github_outputs(tmp_path, monkeypatch, capsys): + schedule = tmp_path / "schedule.json" + schedule.write_text(json.dumps(SCHEDULE)) + output = tmp_path / "constraints.txt" + github_output = tmp_path / "github_output" + monkeypatch.setenv("GITHUB_OUTPUT", str(github_output)) + + code = main( + [ + str(schedule), + "--packages", + "numpy pandas python", + "--output", + str(output), + "--reference-date", + "2025-08-01", + ] + ) + + assert code == 0 + assert output.read_text() == "numpy==2.1.0\npandas==2.2.0\n" + outputs = dict( + line.split("=", 1) for line in github_output.read_text().splitlines() + ) + assert outputs["constraints_file"] == str(output) + assert json.loads(outputs["versions"]) == { + "numpy": "2.1.0", + "pandas": "2.2.0", + "python": "3.13", + } + assert outputs["python_version"] == "3.13" + assert "numpy==2.1.0" in capsys.readouterr().out + + +def test_cli_fails_clearly_on_missing_floor(tmp_path, monkeypatch, capsys): + monkeypatch.delenv("GITHUB_OUTPUT", raising=False) + schedule = tmp_path / "schedule.json" + schedule.write_text(json.dumps(SCHEDULE)) + output = tmp_path / "constraints.txt" + + code = main( + [ + str(schedule), + "--packages", + "numpy zarr", + "--output", + str(output), + "--reference-date", + "2025-08-01", + ] + ) + + assert code == 1 + assert "zarr" in capsys.readouterr().err + assert not output.exists() + + +def test_cli_rejects_bad_reference_date(tmp_path): + schedule = tmp_path / "schedule.json" + schedule.write_text(json.dumps(SCHEDULE)) + with pytest.raises(ValueError, match="YYYY-MM-DD"): + main([str(schedule), "--packages", "numpy", "--reference-date", "01.08.2025"]) + + +def test_bundled_test_schedule(): + versions = minimum_versions( + read_schedule(TEST_SCHEDULE), "numpy scipy pandas", utc(2025, 4, 1) + ) + assert format_constraints(versions) == ( + "numpy==1.26.0\nscipy==1.12.0\npandas==2.1.0\n" + )