diff --git a/.github/workflows/test_action.yaml b/.github/workflows/test_action.yaml index df1f64f..2392af2 100644 --- a/.github/workflows/test_action.yaml +++ b/.github/workflows/test_action.yaml @@ -20,7 +20,7 @@ jobs: name: Run action on test file in repo steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Generate version data using local action uses: ./ with: diff --git a/.github/workflows/test_bench.yml b/.github/workflows/test_bench.yml index b090627..1a1895c 100644 --- a/.github/workflows/test_bench.yml +++ b/.github/workflows/test_bench.yml @@ -15,9 +15,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 - - uses: prefix-dev/setup-pixi@v0.9.5 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6 with: - pixi-version: "v0.49.0" + pixi-version: "v0.69.0" - run: | pixi run test diff --git a/.github/workflows/update_schedule.yml b/.github/workflows/update_schedule.yml new file mode 100644 index 0000000..14d571f --- /dev/null +++ b/.github/workflows/update_schedule.yml @@ -0,0 +1,29 @@ +name: Update SPEC 0 schedule + +on: + schedule: + - cron: "0 0 1 1,4,7,10 *" # Quarterly: 1st Jan, Apr, Jul, Oct + workflow_dispatch: + +jobs: + update-schedule: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6 + with: + pixi-version: "v0.69.0" + - name: Generate schedule files + run: pixi run generate-schedules + - name: Publish schedule as release asset + env: + GH_TOKEN: ${{ github.token }} + run: | + QUARTER="Q$(( ($(date +%-m) - 1) / 3 + 1 ))" + YEAR=$(date +%Y) + TAG="schedule-${YEAR}-${QUARTER}" + gh release create "$TAG" schedule.json schedule.md \ + --title "SPEC 0 Schedule ${YEAR}-${QUARTER}" \ + --notes "Quarterly auto-generated SPEC 0 support schedule. Downloaded automatically by spec0-action." diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a658ddf..de63cbe 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b # frozen: v5.0.0 + rev: v6.0.0 hooks: - id: check-added-large-files - id: check-ast @@ -15,19 +15,19 @@ repos: - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/rbubley/mirrors-prettier - rev: 787fb9f542b140ba0b2aced38e6a3e68021647a3 # frozen: v3.5.3 + rev: v3.8.3 hooks: - id: prettier files: \.(css|html|md|yml|yaml|gql) args: [--prose-wrap=preserve] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: 971923581912ef60a6b70dbf0c3e9a39563c9d47 # frozen: v0.11.4 + rev: v0.15.14 hooks: - - id: ruff + - id: ruff-check args: ["--fix", "--show-fixes", "--exit-non-zero-on-fix"] - id: ruff-format - repo: https://github.com/codespell-project/codespell - rev: "63c8f8312b7559622c0d82815639671ae42132ac" # frozen: v2.4.1 + rev: "v2.4.2" hooks: - id: codespell diff --git a/action.yaml b/action.yaml index 376b7ef..6bbf5bf 100644 --- a/action.yaml +++ b/action.yaml @@ -2,7 +2,6 @@ name: "Update SPEC 0 dependencies" description: "Update the lower bounds of Python dependencies covered by the Scientific Python SPEC 0 support schedule" author: Scientific Python Developers - inputs: target_branch: description: "Target branch for the pull request" @@ -15,7 +14,7 @@ inputs: create_pr: description: "Whether the action should open a PR or not. Set to false for dry-run/testing." required: true - default: true + default: "true" commit_msg: description: "Commit message for the commit to update the versions. by default 'Drop support for unsupported packages conform SPEC 0'. has no effect if `create_pr` is set to false" required: false @@ -25,39 +24,53 @@ inputs: required: false default: "chore: Drop support for unsupported packages conform SPEC 0" schedule_path: - description: "Path to the schedule.json file relative to the project root. If missing, it will be downloaded from the latest release of savente93/SPEC0-schedule" - default: "schedule.json" + description: "Path to the schedule.json file relative to the project root. If not provided, the schedule bundled with the action is used." + required: false + default: "" token: - description: "GitHub token with repo permissions to create pull requests" - required: true - + description: "GitHub token with pull-requests write permission to create pull requests. Defaults to the built-in GITHUB_TOKEN." + required: false + update_all: + description: "If set, also update all non-SPEC0 dependencies to versions released within the last N years (e.g., 2)." + required: false + default: "" runs: using: "composite" steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Git shell: bash run: | git config user.name "Scientific Python [bot]" git config user.email "scientific-python@users.noreply.github.com" - - uses: prefix-dev/setup-pixi@1b2de7f3351f171c8b4dfeb558c639cb58ed4ec0 # v0.9.5 + - uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6 name: Setup Pixi with: - pixi-version: v0.49.0 + pixi-version: v0.69.0 manifest-path: ${{ github.action_path }}/pyproject.toml - - name: Fetch Schedule from release + - name: Fetch schedule from release + if: ${{ inputs.schedule_path == '' }} uses: robinraju/release-downloader@28fc21f50d76778e7023361aa1f863e717d3d56f # v1.13 with: - repository: "savente93/SPEC0-schedule" + repository: "scientific-python/spec0-action" latest: true fileName: "schedule.json" - name: Run update script shell: bash run: | set -e - echo "Updating ${{ inputs.project_file_name }} using schedule ${{ inputs.schedule_path }}" - pixi run --manifest-path ${{ github.action_path }}/pyproject.toml update-dependencies "${{ github.workspace }}/${{ inputs.project_file_name }}" "${{ github.workspace }}/${{ inputs.schedule_path }}" + if [ -n "${{ inputs.schedule_path }}" ]; then + SCHEDULE_PATH="${{ github.workspace }}/${{ inputs.schedule_path }}" + else + SCHEDULE_PATH="${{ github.workspace }}/schedule.json" + fi + echo "Updating ${{ inputs.project_file_name }} using schedule $SCHEDULE_PATH" + UPDATE_ALL_FLAG="" + if [ -n "${{ inputs.update_all }}" ]; then + UPDATE_ALL_FLAG="--update-all ${{ inputs.update_all }}" + fi + pixi run --manifest-path ${{ github.action_path }}/pyproject.toml update-dependencies "${{ github.workspace }}/${{ inputs.project_file_name }}" "$SCHEDULE_PATH" $UPDATE_ALL_FLAG - name: Changes id: changes shell: bash @@ -73,7 +86,7 @@ runs: if: ${{ fromJSON(inputs.create_pr) && fromJSON(steps.changes.outputs.changes_detected) }} uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: - token: ${{ inputs.token }} + token: ${{ inputs.token || github.token }} commit-message: ${{ inputs.commit_msg }} title: ${{ inputs.pr_title }} body: "This PR was created automatically" diff --git a/readme.md b/readme.md index 60fe253..eb3720f 100644 --- a/readme.md +++ b/readme.md @@ -1,27 +1,21 @@ # SPEC 0 Versions Action -This repository contains a Github Action to update Python dependencies in your `pyproject.toml` such that they conform to the SPEC 0 support schedule. -[You can find this schedule here.](https://scientific-python.org/specs/spec-0000/) +A GitHub Action that updates the lower bounds of Python dependencies in `pyproject.toml` to conform to the [SPEC 0 support schedule](https://scientific-python.org/specs/spec-0000/). ## Using the action ### Example workflow -To use the action you can copy the yaml below, and paste it into `.github/workflows/update-spec0.yaml`. -Whenever the action is triggered it will open a PR in your repository that will update the dependencies of SPEC 0 to the new lower bound. -For this you will have to provide it with a PAT that has write permissions in the `contents` and `pull request` scopes. -[Please refer to the GitHub documentation for instructions on how to do this here.](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +Copy the yaml below into `.github/workflows/update-spec0.yaml`. +On each run the action opens a PR updating dependency lower bounds to match the current SPEC 0 schedule. ```yaml name: Update SPEC 0 dependencies on: schedule: - # At 00:00 on day-of-month 3 in every 3rd month. (i.e. every quarter) - # Releases should happen on the second day of the quarter in savente93/SPEC0-schedule to - # avoid fence post errors, so allow one day as a buffer to avoid timing issues here as well. - - cron: "0 0 3 */3 *" - # On demand: + # Day 3 of each quarter. Allows one day buffer after the quarterly schedule release on day 1 + - cron: "0 0 3 1,4,7,10 *" workflow_dispatch: permissions: @@ -32,32 +26,54 @@ jobs: update: runs-on: ubuntu-latest steps: - - uses: scientific-python/spec0-action@v1.0.0 - with: - token: ${{ secrets.GH_PAT }} # <- GH_PAT you will have to configure in the repo as a secret + - uses: scientific-python/spec0-action@v1 ``` -It should update any of the packages listed in the `dependency`, or `tool.pixi.*` tables. -For examples of before and after you can see [./tests/test_data/pyproject.toml](./tests/test_data/pyproject.toml) and [./tests/test_data/pyproject_updated.toml](./tests/test_data/pyproject_updated.toml) respectively. -Other tools are not yet supported, but we are open to feature requests. - -The newest lower bounds will be downloaded from [https://github.com/scientific-python/spec0-action](https://github.com/scientific-python/spec0-action) but you should not have to worry about this. +No PAT required. +The built-in `GITHUB_TOKEN` is used by default as long as the workflow has `pull-requests: write` permission. ### Parameters -| Input | Required | Default | Description | -| ----------------- | -------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| token | yes | — | Personal access token with `contents` & `pull-request` scopes | -| project_file_name | no | `"pyproject.toml"` | File to update dependencies in | -| schedule_path | no | `"schedule.json"` | path to schedule json data. only relevant if you have it committed in your repo | -| target_branch | no | `"main"` | Branch to open PR against | -| create_pr | no | `true` | Open a PR with new versions | -| pr_title | no | `chore: Drop support for unsupported packages conform SPEC 0` | The title of the PR that will be opened | -| commit_msg | no | `chore: Drop support for unsupported packages conform SPEC 0` | Commit message of the commit to update the versions. | +| Input | Required | Default | Description | +| ------------------- | -------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `token` | no | `GITHUB_TOKEN` | Token with `pull-requests: write` permission to open PRs | +| `project_file_name` | no | `pyproject.toml` | Path to the file to update, relative to repository root | +| `schedule_path` | no | — | Path to a custom `schedule.json`, relative to repository root. Uses the latest release if unset | +| `target_branch` | no | `main` | Branch to open the PR against | +| `create_pr` | no | `true` | Set to `false` for a dry run | +| `pr_title` | no | `chore: Drop support for unsupported packages conform SPEC 0` | Title of the opened PR | +| `commit_msg` | no | `chore: Drop support for unsupported packages conform SPEC 0` | Commit message for the version update commit | +| `update_all` | no | — | If set to a number N, also update non-SPEC0 dependencies to versions released within the last N years (e.g. `2`) | + +For examples of before/after see [tests/test_data/pyproject.toml](./tests/test_data/pyproject.toml) and [tests/test_data/pyproject_updated.toml](./tests/test_data/pyproject_updated.toml). ## Limitations -1. Since this action simply parses the toml to do the upgrade and leaves any other bounds intact, it is possible that the environment of the PR becomes unsolvable. - For example if you have a numpy dependency like so: `numpy = ">=1.25.0,<2"` this will get updated in the PR to `numpy = ">=2.0.0,<2"` which is infeasible. - Keeping the resulting environment solvable is outside the scope of this action, so you might have to adjust them manually. -2. Currently only `pyproject.toml` is supported by this action, though other manifest files could be considered upon request. +1. The action only tightens lower bounds and leaves upper bounds untouched. An update can produce an unsolvable environment — for example `numpy = ">=1.25.0,<2"` becomes `numpy = ">=2.0.0,<2"`. Keeping the environment solvable is out of scope; adjust upper bounds manually if needed. +2. Only `pyproject.toml` is currently supported. + +## Maintainer notes + +### Releasing a new action version + +Action versions are **git tags only**, do not create a GitHub Release for them. GitHub Releases in this repository are reserved for the quarterly schedule data. + +```bash +git tag v1.x +git push origin v1.x +``` + +### Schedule releases + +The SPEC 0 schedule (`schedule.json` and `schedule.md`) is published as a GitHub Release quarterly by the [Update SPEC 0 schedule](./.github/workflows/update_schedule.yml) workflow. Releases are tagged `schedule-YYYY-QN` (e.g. `schedule-2026-Q2`). + +The action always fetches `schedule.json` from the **latest** GitHub Release in this repository, which will always be a schedule release as long as action versions are never published as releases. + +#### Bootstrap + +Before the first quarterly schedule release exists, the action will fail. To create the initial release, trigger the workflow manually: + +1. Go to **Actions → Update SPEC 0 schedule** +2. Click **Run workflow** + +Subsequent releases are created automatically on the 1st of January, April, July, and October. diff --git a/run_spec0_update.py b/run_spec0_update.py index c03981e..2031bfb 100644 --- a/run_spec0_update.py +++ b/run_spec0_update.py @@ -7,7 +7,6 @@ parser = ArgumentParser( description="A script to update your project dependencies to be in line with the scientific python SPEC 0 support schedule", ) - parser.add_argument( "toml_path", default="pyproject.toml", @@ -18,24 +17,25 @@ default="schedule.json", help="Path to the schedule json payload. defaults to 'schedule.json'", ) - + parser.add_argument( + "--update-all", + type=float, + default=None, + metavar="YEARS", + help="Also update all non-SPEC0 dependencies to versions released within the last YEARS years (e.g., 2).", + ) args = parser.parse_args() - toml_path = Path(args.toml_path) schedule_path = Path(args.schedule_path) - if not toml_path.exists(): raise ValueError( f"{toml_path} was supplied as path to project file but it did not exist" ) - if not schedule_path.exists(): raise ValueError( f"{schedule_path} was supplied as path to schedule file but it did not exist" ) - project_data = read_toml(toml_path) schedule_data = read_schedule(schedule_path) - update_pyproject_toml(project_data, schedule_data) - + update_pyproject_toml(project_data, schedule_data, update_all=args.update_all) write_toml(toml_path, project_data) diff --git a/spec0_action/__init__.py b/spec0_action/__init__.py index 7656721..6a89f70 100644 --- a/spec0_action/__init__.py +++ b/spec0_action/__init__.py @@ -1,6 +1,8 @@ +import contextlib from packaging.specifiers import SpecifierSet from typing import Sequence, Dict import datetime +import requests from spec0_action.versions import repr_spec_set, tighten_lower_bound from spec0_action.parsing import ( @@ -13,31 +15,71 @@ read_toml, write_toml, ) -from packaging.version import Version +from packaging.version import Version, InvalidVersion __all__ = ["read_schedule", "read_toml", "write_toml", "update_pyproject_toml"] + +def _get_oldest_version_in_window(package: str, years: float) -> Version | None: + """ + Query PyPI, return oldest non-pre release version uploaded within the last ``years`` years. + """ + cutoff = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta( + days=int(365 * years) + ) + try: + resp = requests.get( + f"https://pypi.org/simple/{package}", + headers={"Accept": "application/vnd.pypi.simple.v1+json"}, + timeout=15, + ) + resp.raise_for_status() + data = resp.json() + except Exception: + return None + candidates: list[Version] = [] + for f in data.get("files", []): + parts = f.get("filename", "").split("-") + if len(parts) < 2: + continue + try: + ver = Version(parts[1]) + except InvalidVersion: + continue + if ver.is_prerelease: + continue + upload_str = f.get("upload-time", "") + upload_time = None + for fmt in ["%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"]: + with contextlib.suppress(ValueError): + upload_time = datetime.datetime.strptime(upload_str, fmt).replace( + tzinfo=datetime.timezone.utc + ) + break + if upload_time is None or upload_time < cutoff: + continue + candidates.append(ver) + + return min(candidates, default=None) + + def update_pyproject_dependencies(dependencies: dict, schedule: Dict[str, str]): # Iterate by idx because we want to update it inplace for i in range(len(dependencies)): dep_str = dependencies[i] pkg, extras, spec, env = parse_pep_dependency(dep_str) - if isinstance(spec, Url) or pkg not in schedule: continue - new_lower_bound = Version(schedule[pkg]) try: spec = tighten_lower_bound(spec or SpecifierSet(), new_lower_bound) # Will raise a value error if bound is already tighter, in this case we just do nothing and continue except ValueError: continue - if not extras: new_dep_str = f"{pkg}{repr_spec_set(spec)}{env or ''}" else: new_dep_str = f"{pkg}{extras}{repr_spec_set(spec)}{env or ''}" - dependencies[i] = new_dep_str @@ -46,17 +88,13 @@ def update_dependency_table(dep_table: dict, new_versions: dict): # Don't do anything for pkgs that aren't in our schedule if pkg not in new_versions: continue - # Like pkg = ">x.y.z,=3.7.0,<4"), spec @@ -43,7 +40,6 @@ def test_pep_dependency_parsing_with_spec_and_optional_dep(): def test_pep_dependency_parsing_with_spec(): matplotlib_str = "matplotlib>=3.7.0,<4" pkg, features, spec, env = parse_pep_dependency(matplotlib_str) - assert pkg == "matplotlib", pkg assert features is None, features assert spec == SpecifierSet(">=3.7.0,<4"), spec @@ -53,7 +49,6 @@ def test_pep_dependency_parsing_with_spec(): def test_pep_dependency_parsing_with_url_spec(): dep_str = "matplotlib @ https://github.com/pypa/pip/archive/1.3.1.zip#sha1=da9234ee9982d4bbb3c72346a6de940a148ea686" pkg, features, spec, env = parse_pep_dependency(dep_str) - assert pkg == "matplotlib", pkg assert features is None, features assert spec == urlparse( @@ -65,7 +60,6 @@ def test_pep_dependency_parsing_with_url_spec(): def test_pep_dependency_parsing_extra_restrictions(): matplotlib_str = "matplotlib>=3.7.0,<4,!=3.8.14" pkg, features, spec, env = parse_pep_dependency(matplotlib_str) - assert pkg == "matplotlib", pkg assert features is None, features assert spec == SpecifierSet("!=3.8.14,<4,>=3.7.0"), spec @@ -75,7 +69,6 @@ def test_pep_dependency_parsing_extra_restrictions(): def test_pep_dependency_parsing_with_environment_marker(): matplotlib_str = "matplotlib>=3.7.0,<4;sys_platform != 'win32'" pkg, features, spec, env = parse_pep_dependency(matplotlib_str) - assert pkg == "matplotlib", pkg assert features is None, features assert spec == SpecifierSet(">=3.7.0,<4"), spec diff --git a/tests/test_update_pyproject_toml.py b/tests/test_update_pyproject_toml.py index f1dd998..dd69c42 100644 --- a/tests/test_update_pyproject_toml.py +++ b/tests/test_update_pyproject_toml.py @@ -1,22 +1,25 @@ import datetime +from unittest.mock import patch import pytest +from packaging.version import Version from spec0_action.parsing import read_schedule, read_toml from spec0_action import update_pyproject_toml +import spec0_action -# Fixed time to avoid test results changing over time... +# Fixed time to avoid test results changing over time FAKE_TIME = datetime.datetime(2025, 10, 30, 0, 0, 0, tzinfo=datetime.UTC) + @pytest.fixture def patch_datetime_now(monkeypatch): - class mydatetime(datetime.datetime): @classmethod def now(cls, *args, **kwds): return FAKE_TIME - monkeypatch.setattr(datetime, 'datetime', mydatetime) + monkeypatch.setattr(datetime, "datetime", mydatetime) def test_update_pyproject_toml(patch_datetime_now): @@ -28,10 +31,61 @@ def test_update_pyproject_toml(patch_datetime_now): assert pyproject_data == expected -def test_update_pyproject_toml_with_pixi(): +def test_update_pyproject_toml_with_pixi(patch_datetime_now): expected = read_toml("tests/test_data/pyproject_pixi_updated.toml") pyproject_data = read_toml("tests/test_data/pyproject_pixi.toml") test_schedule = read_schedule("tests/test_data/test_schedule.json") update_pyproject_toml(pyproject_data, test_schedule) - assert pyproject_data == expected + + +def _minimal_pyproject(*deps): + return { + "project": { + "requires-python": ">=3.11", + "dependencies": list(deps), + } + } + + +def test_update_all_updates_non_spec0_package(patch_datetime_now): + pyproject = _minimal_pyproject("requests>=2.0.0", "numpy>=1.10.0") + schedule = read_schedule("tests/test_data/test_schedule.json") + with patch.object( + spec0_action, "_get_oldest_version_in_window", return_value=Version("2.28.0") + ): + update_pyproject_toml(pyproject, schedule, update_all=2.0) + deps = pyproject["project"]["dependencies"] + # requests is not not in SPEC 0 and should be bumped to >=2.28.0 + assert "requests>=2.28.0" in deps + # numpy is in SPEC 0 schedule so it should be handled by spec0 logic and not the flag + assert all("requests" not in d or "2.28.0" in d for d in deps) + + +def test_update_all_skips_spec0_packages(patch_datetime_now): + pyproject = _minimal_pyproject("numpy>=1.10.0") + schedule = read_schedule("tests/test_data/test_schedule.json") + with patch.object(spec0_action, "_get_oldest_version_in_window") as mock_pypi: + update_pyproject_toml(pyproject, schedule, update_all=2.0) + # numpy is in the SPEC 0 schedule, _get_oldest_version_in_window must not be called for it + for call_args in mock_pypi.call_args_list: + assert call_args[0][0] != "numpy" + + +def test_update_all_skips_already_strict_bound(patch_datetime_now): + pyproject = _minimal_pyproject("requests>=2.32.0") + schedule = read_schedule("tests/test_data/test_schedule.json") + # PyPI returns an older version than what's already pinned, therefore the bound must not regress + with patch.object( + spec0_action, "_get_oldest_version_in_window", return_value=Version("2.28.0") + ): + update_pyproject_toml(pyproject, schedule, update_all=2.0) + assert pyproject["project"]["dependencies"] == ["requests>=2.32.0"] + + +def test_update_all_noop_when_not_set(patch_datetime_now): + pyproject = _minimal_pyproject("requests>=2.0.0", "numpy>=1.10.0") + schedule = read_schedule("tests/test_data/test_schedule.json") + with patch.object(spec0_action, "_get_oldest_version_in_window") as mock_pypi: + update_pyproject_toml(pyproject, schedule) + mock_pypi.assert_not_called()