Skip to content

Commit 7a615d6

Browse files
committed
WIP
1 parent 4a1b283 commit 7a615d6

9 files changed

Lines changed: 199 additions & 61 deletions

File tree

.github/workflows/test_action.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
name: Run action on test file in repo
2121
steps:
2222
- name: Checkout
23-
uses: actions/checkout@v6
23+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
2424
- name: Generate version data using local action
2525
uses: ./
2626
with:

.github/workflows/test_bench.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ jobs:
1515
runs-on: ubuntu-latest
1616
steps:
1717
- name: Checkout
18-
uses: actions/checkout@v6
19-
- uses: prefix-dev/setup-pixi@v0.9.5
18+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
19+
- uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6
2020
with:
21-
pixi-version: "v0.49.0"
21+
pixi-version: "v0.69.0"
2222
- run: |
2323
pixi run test
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Update SPEC 0 schedule
2+
3+
on:
4+
schedule:
5+
- cron: "0 0 1 1,4,7,10 *" # Quarterly: 1st Jan, Apr, Jul, Oct
6+
workflow_dispatch:
7+
8+
jobs:
9+
update-schedule:
10+
runs-on: ubuntu-latest
11+
permissions:
12+
contents: write
13+
steps:
14+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
15+
- uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6
16+
with:
17+
pixi-version: "v0.69.0"
18+
- name: Generate schedule files
19+
run: pixi run generate-schedules
20+
- name: Publish schedule as release asset
21+
env:
22+
GH_TOKEN: ${{ github.token }}
23+
run: |
24+
QUARTER="Q$(( ($(date +%-m) - 1) / 3 + 1 ))"
25+
YEAR=$(date +%Y)
26+
TAG="schedule-${YEAR}-${QUARTER}"
27+
gh release create "$TAG" schedule.json schedule.md \
28+
--title "SPEC 0 Schedule ${YEAR}-${QUARTER}" \
29+
--notes "Quarterly auto-generated SPEC 0 support schedule. Downloaded automatically by spec0-action."

.pre-commit-config.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b # frozen: v5.0.0
3+
rev: v6.0.0
44
hooks:
55
- id: check-added-large-files
66
- id: check-ast
@@ -15,19 +15,19 @@ repos:
1515
- id: mixed-line-ending
1616
- id: trailing-whitespace
1717
- repo: https://github.com/rbubley/mirrors-prettier
18-
rev: 787fb9f542b140ba0b2aced38e6a3e68021647a3 # frozen: v3.5.3
18+
rev: v3.8.3
1919
hooks:
2020
- id: prettier
2121
files: \.(css|html|md|yml|yaml|gql)
2222
args: [--prose-wrap=preserve]
2323
- repo: https://github.com/astral-sh/ruff-pre-commit
24-
rev: 971923581912ef60a6b70dbf0c3e9a39563c9d47 # frozen: v0.11.4
24+
rev: v0.15.14
2525
hooks:
26-
- id: ruff
26+
- id: ruff-check
2727
args: ["--fix", "--show-fixes", "--exit-non-zero-on-fix"]
2828
- id: ruff-format
2929
- repo: https://github.com/codespell-project/codespell
30-
rev: "63c8f8312b7559622c0d82815639671ae42132ac" # frozen: v2.4.1
30+
rev: "v2.4.2"
3131
hooks:
3232
- id: codespell
3333

action.yaml

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
name: "Update SPEC 0 dependencies"
33
description: "Update the lower bounds of Python dependencies covered by the Scientific Python SPEC 0 support schedule"
44
author: Scientific Python Developers
5-
65
inputs:
76
target_branch:
87
description: "Target branch for the pull request"
@@ -15,7 +14,7 @@ inputs:
1514
create_pr:
1615
description: "Whether the action should open a PR or not. Set to false for dry-run/testing."
1716
required: true
18-
default: true
17+
default: "true"
1918
commit_msg:
2019
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"
2120
required: false
@@ -25,39 +24,53 @@ inputs:
2524
required: false
2625
default: "chore: Drop support for unsupported packages conform SPEC 0"
2726
schedule_path:
28-
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"
29-
default: "schedule.json"
27+
description: "Path to the schedule.json file relative to the project root. If not provided, the schedule bundled with the action is used."
28+
required: false
29+
default: ""
3030
token:
31-
description: "GitHub token with repo permissions to create pull requests"
32-
required: true
33-
31+
description: "GitHub token with pull-requests write permission to create pull requests. Defaults to the built-in GITHUB_TOKEN."
32+
required: false
33+
update_all:
34+
description: "If set, also update all non-SPEC0 dependencies to versions released within the last N years (e.g., 2)."
35+
required: false
36+
default: ""
3437
runs:
3538
using: "composite"
3639
steps:
3740
- name: Checkout code
38-
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
41+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
3942
- name: Set up Git
4043
shell: bash
4144
run: |
4245
git config user.name "Scientific Python [bot]"
4346
git config user.email "scientific-python@users.noreply.github.com"
44-
- uses: prefix-dev/setup-pixi@1b2de7f3351f171c8b4dfeb558c639cb58ed4ec0 # v0.9.5
47+
- uses: prefix-dev/setup-pixi@5185adfbffb4bd703da3010310260805d89ebb11 # v0.9.6
4548
name: Setup Pixi
4649
with:
47-
pixi-version: v0.49.0
50+
pixi-version: v0.69.0
4851
manifest-path: ${{ github.action_path }}/pyproject.toml
49-
- name: Fetch Schedule from release
52+
- name: Fetch schedule from release
53+
if: ${{ inputs.schedule_path == '' }}
5054
uses: robinraju/release-downloader@28fc21f50d76778e7023361aa1f863e717d3d56f # v1.13
5155
with:
52-
repository: "savente93/SPEC0-schedule"
56+
repository: "scientific-python/spec0-action"
5357
latest: true
5458
fileName: "schedule.json"
5559
- name: Run update script
5660
shell: bash
5761
run: |
5862
set -e
59-
echo "Updating ${{ inputs.project_file_name }} using schedule ${{ inputs.schedule_path }}"
60-
pixi run --manifest-path ${{ github.action_path }}/pyproject.toml update-dependencies "${{ github.workspace }}/${{ inputs.project_file_name }}" "${{ github.workspace }}/${{ inputs.schedule_path }}"
63+
if [ -n "${{ inputs.schedule_path }}" ]; then
64+
SCHEDULE_PATH="${{ github.workspace }}/${{ inputs.schedule_path }}"
65+
else
66+
SCHEDULE_PATH="${{ github.workspace }}/schedule.json"
67+
fi
68+
echo "Updating ${{ inputs.project_file_name }} using schedule $SCHEDULE_PATH"
69+
UPDATE_ALL_FLAG=""
70+
if [ -n "${{ inputs.update_all }}" ]; then
71+
UPDATE_ALL_FLAG="--update-all ${{ inputs.update_all }}"
72+
fi
73+
pixi run --manifest-path ${{ github.action_path }}/pyproject.toml update-dependencies "${{ github.workspace }}/${{ inputs.project_file_name }}" "$SCHEDULE_PATH" $UPDATE_ALL_FLAG
6174
- name: Changes
6275
id: changes
6376
shell: bash
@@ -73,7 +86,7 @@ runs:
7386
if: ${{ fromJSON(inputs.create_pr) && fromJSON(steps.changes.outputs.changes_detected) }}
7487
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
7588
with:
76-
token: ${{ inputs.token }}
89+
token: ${{ inputs.token || github.token }}
7790
commit-message: ${{ inputs.commit_msg }}
7891
title: ${{ inputs.pr_title }}
7992
body: "This PR was created automatically"

run_spec0_update.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
parser = ArgumentParser(
88
description="A script to update your project dependencies to be in line with the scientific python SPEC 0 support schedule",
99
)
10-
1110
parser.add_argument(
1211
"toml_path",
1312
default="pyproject.toml",
@@ -18,24 +17,25 @@
1817
default="schedule.json",
1918
help="Path to the schedule json payload. defaults to 'schedule.json'",
2019
)
21-
20+
parser.add_argument(
21+
"--update-all",
22+
type=float,
23+
default=None,
24+
metavar="YEARS",
25+
help="Also update all non-SPEC0 dependencies to versions released within the last YEARS years (e.g., 2).",
26+
)
2227
args = parser.parse_args()
23-
2428
toml_path = Path(args.toml_path)
2529
schedule_path = Path(args.schedule_path)
26-
2730
if not toml_path.exists():
2831
raise ValueError(
2932
f"{toml_path} was supplied as path to project file but it did not exist"
3033
)
31-
3234
if not schedule_path.exists():
3335
raise ValueError(
3436
f"{schedule_path} was supplied as path to schedule file but it did not exist"
3537
)
36-
3738
project_data = read_toml(toml_path)
3839
schedule_data = read_schedule(schedule_path)
39-
update_pyproject_toml(project_data, schedule_data)
40-
40+
update_pyproject_toml(project_data, schedule_data, update_all=args.update_all)
4141
write_toml(toml_path, project_data)

spec0_action/__init__.py

Lines changed: 66 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import contextlib
12
from packaging.specifiers import SpecifierSet
23
from typing import Sequence, Dict
34
import datetime
5+
import requests
46

57
from spec0_action.versions import repr_spec_set, tighten_lower_bound
68
from spec0_action.parsing import (
@@ -13,31 +15,71 @@
1315
read_toml,
1416
write_toml,
1517
)
16-
from packaging.version import Version
18+
from packaging.version import Version, InvalidVersion
1719

1820
__all__ = ["read_schedule", "read_toml", "write_toml", "update_pyproject_toml"]
1921

22+
23+
def _get_oldest_version_in_window(package: str, years: float) -> Version | None:
24+
"""
25+
Query PyPI, return oldest non-pre release version uploaded within the last ``years`` years.
26+
"""
27+
cutoff = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta(
28+
days=int(365 * years)
29+
)
30+
try:
31+
resp = requests.get(
32+
f"https://pypi.org/simple/{package}",
33+
headers={"Accept": "application/vnd.pypi.simple.v1+json"},
34+
timeout=15,
35+
)
36+
resp.raise_for_status()
37+
data = resp.json()
38+
except Exception:
39+
return None
40+
candidates: list[Version] = []
41+
for f in data.get("files", []):
42+
parts = f.get("filename", "").split("-")
43+
if len(parts) < 2:
44+
continue
45+
try:
46+
ver = Version(parts[1])
47+
except InvalidVersion:
48+
continue
49+
if ver.is_prerelease:
50+
continue
51+
upload_str = f.get("upload-time", "")
52+
upload_time = None
53+
for fmt in ["%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"]:
54+
with contextlib.suppress(ValueError):
55+
upload_time = datetime.datetime.strptime(upload_str, fmt).replace(
56+
tzinfo=datetime.timezone.utc
57+
)
58+
break
59+
if upload_time is None or upload_time < cutoff:
60+
continue
61+
candidates.append(ver)
62+
63+
return min(candidates, default=None)
64+
65+
2066
def update_pyproject_dependencies(dependencies: dict, schedule: Dict[str, str]):
2167
# Iterate by idx because we want to update it inplace
2268
for i in range(len(dependencies)):
2369
dep_str = dependencies[i]
2470
pkg, extras, spec, env = parse_pep_dependency(dep_str)
25-
2671
if isinstance(spec, Url) or pkg not in schedule:
2772
continue
28-
2973
new_lower_bound = Version(schedule[pkg])
3074
try:
3175
spec = tighten_lower_bound(spec or SpecifierSet(), new_lower_bound)
3276
# Will raise a value error if bound is already tighter, in this case we just do nothing and continue
3377
except ValueError:
3478
continue
35-
3679
if not extras:
3780
new_dep_str = f"{pkg}{repr_spec_set(spec)}{env or ''}"
3881
else:
3982
new_dep_str = f"{pkg}{extras}{repr_spec_set(spec)}{env or ''}"
40-
4183
dependencies[i] = new_dep_str
4284

4385

@@ -46,17 +88,13 @@ def update_dependency_table(dep_table: dict, new_versions: dict):
4688
# Don't do anything for pkgs that aren't in our schedule
4789
if pkg not in new_versions:
4890
continue
49-
5091
# Like pkg = ">x.y.z,<a"
5192
if isinstance(pkg_data, str):
5293
if not is_url_spec(pkg_data):
5394
spec = parse_version_spec(pkg_data)
54-
5595
new_lower_bound = Version(new_versions[pkg])
5696
spec = tighten_lower_bound(spec, new_lower_bound)
57-
5897
dep_table[pkg] = repr_spec_set(spec)
59-
6098
else:
6199
# We don't do anything with url spec dependencies
62100
continue
@@ -65,18 +103,16 @@ def update_dependency_table(dep_table: dict, new_versions: dict):
65103
if "path" in pkg_data:
66104
# We don't do anything with path dependencies
67105
continue
68-
69106
spec = SpecifierSet(pkg_data["version"])
70107
new_lower_bound = Version(new_versions[pkg])
71108
spec = tighten_lower_bound(spec, new_lower_bound)
72-
73109
pkg_data["version"] = repr_spec_set(spec)
74110

75111

76112
def update_pixi_dependencies(pixi_tables: dict, schedule: Dict[str, str]):
77-
if "pypi-dependencies" in pixi_tables:
113+
if "pypi-dependencies" in pixi_tables:
78114
update_dependency_table(pixi_tables["pypi-dependencies"], schedule)
79-
if "dependencies" in pixi_tables:
115+
if "dependencies" in pixi_tables:
80116
update_dependency_table(pixi_tables["dependencies"], schedule)
81117

82118
if "feature" in pixi_tables:
@@ -86,7 +122,9 @@ def update_pixi_dependencies(pixi_tables: dict, schedule: Dict[str, str]):
86122

87123

88124
def update_pyproject_toml(
89-
pyproject_data: dict, schedule_data: Sequence[SupportSchedule]
125+
pyproject_data: dict,
126+
schedule_data: Sequence[SupportSchedule],
127+
update_all: float | None = None,
90128
):
91129
now = datetime.datetime.now(datetime.UTC)
92130
applicable = sorted(
@@ -101,20 +139,31 @@ def update_pyproject_toml(
101139
# Fill in the latest known requirement (schedule is sorted, newer entries overwrite older)
102140
for pkg, version in schedule["packages"].items():
103141
new_version[pkg] = version
104-
105142
if not new_version:
106143
raise RuntimeError(
107144
"Could not find schedule that applies to current time, perhaps your schedule is outdated."
108145
)
109-
110146
if "python" in new_version:
111147
pyproject_data["project"]["requires-python"] = repr_spec_set(
112148
parse_version_spec(new_version["python"])
113149
)
114150
update_pyproject_dependencies(
115151
pyproject_data["project"]["dependencies"], new_version
116152
)
117-
118153
if "tool" in pyproject_data and "pixi" in pyproject_data["tool"]:
119154
pixi_data = pyproject_data["tool"]["pixi"]
120155
update_pixi_dependencies(pixi_data, new_version)
156+
if update_all is not None:
157+
deps = pyproject_data.get("project", {}).get("dependencies", [])
158+
for i, dep_str in enumerate(deps):
159+
pkg, extras, spec, env = parse_pep_dependency(dep_str)
160+
if pkg in new_version or isinstance(spec, Url) or spec is None:
161+
continue
162+
min_ver = _get_oldest_version_in_window(pkg, update_all)
163+
if min_ver is None:
164+
continue
165+
try:
166+
updated = tighten_lower_bound(spec, min_ver)
167+
deps[i] = f"{pkg}{extras or ''}{repr_spec_set(updated)}{env or ''}"
168+
except ValueError:
169+
continue

0 commit comments

Comments
 (0)