Skip to content

Commit 54644a5

Browse files
tsbinnsautofix-ci[bot]drammocklarsoner
authored
[MAINT] Automatic SPEC0 dependency version management (#13451)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel McCloy <[email protected]> Co-authored-by: Eric Larson <[email protected]>
1 parent 181fea1 commit 54644a5

File tree

5 files changed

+324
-1
lines changed

5 files changed

+324
-1
lines changed

.github/workflows/spec_zero.yml

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
name: SPEC0
2+
3+
on: # yamllint disable-line rule:truthy
4+
schedule:
5+
- cron: '0 0 * * 1' # At 00:00 every Monday
6+
workflow_dispatch:
7+
inputs:
8+
ssh:
9+
description: 'Enable ssh debugging'
10+
required: false
11+
default: false
12+
type: boolean
13+
14+
jobs:
15+
update_versions:
16+
permissions:
17+
contents: write
18+
pull-requests: write
19+
name: Update dependency versions
20+
runs-on: ubuntu-latest
21+
env:
22+
GH_TOKEN: ${{ github.token }}
23+
GITHUB_TOKEN: ${{ github.token }}
24+
steps:
25+
- uses: actions/checkout@v5
26+
with:
27+
persist-credentials: true
28+
- name: Triage SSH
29+
run: |
30+
if [[ "${{ inputs.ssh }}" == "true" ]] || [[ "$COMMIT_MESSAGE" == *"[actions ssh]"* ]]; then
31+
echo "ENABLE_SSH=true" | tee -a $GITHUB_ENV
32+
else
33+
echo "ENABLE_SSH=false" | tee -a $GITHUB_ENV
34+
fi
35+
- name: Setup Remote SSH Connection
36+
if: env.ENABLE_SSH == 'true'
37+
uses: mxschmitt/action-tmate@v3
38+
timeout-minutes: 10
39+
with:
40+
detached: true
41+
- uses: actions/setup-python@v6
42+
with:
43+
python-version: '3.12'
44+
- run: pip install packaging requests tomlkit
45+
- run: python tools/dev/spec_zero_update_versions.py
46+
- run: |
47+
git diff && git status --porcelain
48+
if [[ $(git status --porcelain) ]]; then
49+
echo "dirty=true" >> $GITHUB_OUTPUT
50+
fi
51+
id: status
52+
- name: Create PR
53+
run: |
54+
set -xeo pipefail
55+
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
56+
git config --global user.name "github-actions[bot]"
57+
git checkout -b spec_zero
58+
git commit -am "MAINT: Update dependency specifiers"
59+
git push origin spec_zero
60+
PR_NUM=$(gh pr create --base main --head spec_zero --title "MAINT: Update dependency specifiers" --body "Created by spec_zero [GitHub action](https://github.com/mne-tools/mne-python/actions/runs/${{ github.run_id }})." --label "no-changelog-entry-needed")
61+
echo "Opened https://github.com/mne-tools/mne-python/pull/${PR_NUM}" >> $GITHUB_STEP_SUMMARY
62+
if: steps.status.outputs.dirty == 'true'

.yamllint.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ ignore: |
66
rules:
77
line-length: disable
88
document-start: disable
9+
new-lines:
10+
type: platform

doc/changes/dev/13451.other.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add a SPEC0-like policy in which minimum-supported versions for select dependencies are set to the latest minor release that was available 2 years prior, by `Thomas Binns`_

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ full-no-qt = [
111111
"nibabel",
112112
"nilearn",
113113
"numba",
114-
"openmeeg >= 2.5.7", # released: 2023/11/16
114+
"openmeeg >= 2.5.7",
115115
"pandas >= 2.1", # released: 2023/08/30
116116
"pillow", # for `Brain.save_image` and `mne.Report`
117117
"pyarrow", # only needed to avoid a deprecation warning in pandas
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
"""Update version specifiers of certain dependencies to comply with SPEC0-like rules.
2+
3+
SPEC0 (https://scientific-python.org/specs/spec-0000/) recommends that support for
4+
Python versions are dropped 3 years after initial release, and for core package
5+
dependencies 2 years after initial release.
6+
7+
MNE-Python follows a SPEC0-like policy that reduces maintenance burden whilst
8+
accommodating users in minimum version support similarly to when before this policy was
9+
adopted.
10+
11+
MNE-Python's policy differs from SPEC0 in the following ways:
12+
- Python versions are supported for at least 3 years after release, but possibly longer
13+
at the discretion of the MNE-Python maintainers based on, e.g., maintainability,
14+
features.
15+
- Not all core dependencies have minimum versions pinned, and some optional dependencies
16+
have minimum versions pinned. Only those dependencies whose older versions require
17+
considerable work to maintain compatibility with (e.g., due to API changes) have
18+
minimum versions pinned.
19+
- Micro/patch versions are not pinned as minimum versions (unless there is an issue with
20+
a specific patch), as these should not introduce breaking changes.
21+
- Minimum versions for dependencies are set to the latest minor release that was
22+
available 2 years prior. The rationale behind this is discussed here:
23+
https://github.com/mne-tools/mne-python/pull/13451#discussion_r2445337934
24+
25+
For example, in October 2025:
26+
- The latest version of NumPy available 2 years prior was 1.26.1 (released October
27+
2023), making the latest minor release 1.26, which would be pinned. Support for 1.26
28+
would be dropped in June 2026 in favour of 2.0, which was released in June 2024.
29+
- The latest version of SciPy available 2 years prior was 1.11.3 (release September
30+
2023), making the latest minor release 1.11, which would be pinned. Support for 1.11
31+
would be dropped in January 2026 in favour of 1.12, which was released in January
32+
2024.
33+
"""
34+
35+
# Authors: The MNE-Python contributors.
36+
# License: BSD-3-Clause
37+
# Copyright the MNE-Python contributors.
38+
39+
import collections
40+
import datetime
41+
import re
42+
43+
import requests
44+
from packaging.requirements import Requirement
45+
from packaging.specifiers import SpecifierSet
46+
from packaging.version import InvalidVersion, Version
47+
from tomlkit import parse
48+
from tomlkit.items import Comment, Trivia
49+
from tomlkit.toml_file import TOMLFile
50+
51+
SORT_PACKAGES = [
52+
"matplotlib",
53+
"numpy",
54+
"pandas",
55+
"pyvista",
56+
"pyvistaqt",
57+
"scikit-learn",
58+
"scipy",
59+
]
60+
SUPPORT_TIME = datetime.timedelta(days=365 * 2)
61+
CURRENT_DATE = datetime.datetime.now()
62+
63+
64+
def get_release_and_drop_dates(package):
65+
"""Get release and drop dates for a given package from pypi.org."""
66+
releases = {}
67+
print(f"Querying pypi.org for {package} versions...", end="", flush=True)
68+
response = requests.get(
69+
f"https://pypi.org/simple/{package}",
70+
headers={"Accept": "application/vnd.pypi.simple.v1+json"},
71+
timeout=10,
72+
).json()
73+
print("OK")
74+
file_date = collections.defaultdict(list)
75+
for f in response["files"]:
76+
if f["filename"].endswith(".tar.gz") or f["filename"].endswith(".zip"):
77+
continue
78+
if f["yanked"]:
79+
continue
80+
ver = f["filename"].split("-")[1]
81+
try:
82+
version = Version(ver)
83+
except InvalidVersion:
84+
continue
85+
if version.is_prerelease:
86+
continue
87+
release_date = datetime.datetime.fromisoformat(f["upload-time"]).replace(
88+
tzinfo=None
89+
)
90+
if not release_date:
91+
continue
92+
file_date[version].append(release_date)
93+
release_date = {v: min(file_date[v]) for v in file_date}
94+
for ver, release_date in sorted(release_date.items()):
95+
cutoff_date = CURRENT_DATE - SUPPORT_TIME
96+
pre_cutoff = bool(release_date <= cutoff_date) # was available X time ago
97+
releases[ver] = {"release_date": release_date, "pre_cutoff": pre_cutoff}
98+
return releases
99+
100+
101+
def update_specifiers(dependencies, releases):
102+
"""Update dependency version specifiers."""
103+
for idx, dep in enumerate(dependencies):
104+
req = Requirement(dep)
105+
pkg_name = req.name
106+
pkg_spec = req.specifier
107+
if pkg_name in releases.keys(): # check if this is a package to update
108+
# Find package versions matching current specifiers
109+
package_vers = releases[pkg_name].keys()
110+
matches = list(pkg_spec.filter(package_vers)) # drop excluded versions
111+
pre_cutoff_matches = [
112+
ver for ver in matches if releases[pkg_name][ver]["pre_cutoff"]
113+
]
114+
if len(pre_cutoff_matches) == 0:
115+
raise RuntimeError(
116+
f"{pkg_name} had no versions available {SUPPORT_TIME.days / 365} "
117+
"years ago compliant with the following specifier(s): "
118+
f"{pkg_spec if pkg_spec else 'None'}",
119+
)
120+
post_cutoff_matches = [
121+
ver for ver in matches if not releases[pkg_name][ver]["pre_cutoff"]
122+
]
123+
124+
# Find latest pre-cutoff version to pin as the minimum version
125+
min_ver = max(pre_cutoff_matches)
126+
min_ver, min_ver_release = _find_version_to_pin_and_release(
127+
min_ver, pkg_spec, pre_cutoff_matches, releases[pkg_name]
128+
)
129+
130+
# Find earliest post-cutoff version to pin next
131+
next_ver = None
132+
next_ver_release = None
133+
for ver in post_cutoff_matches:
134+
if _as_minor_version(ver) > min_ver: # if a new minor version
135+
next_ver, next_ver_release = _find_version_to_pin_and_release(
136+
ver, pkg_spec, post_cutoff_matches, releases[pkg_name]
137+
)
138+
break
139+
140+
# Update specifiers with new minimum version
141+
min_ver_spec = SpecifierSet(f">={str(min_ver)}")
142+
new_spec = [str(min_ver_spec)]
143+
for spec in str(pkg_spec).split(","):
144+
spec = spec.strip()
145+
if spec.startswith(">"):
146+
continue # ignore old min ver
147+
if spec.startswith("!=") and not min_ver_spec.contains(spec[2:]):
148+
continue # ignore outdated exclusions
149+
new_spec.append(spec) # keep max vers and in-date exclusions
150+
req.specifier = SpecifierSet(",".join(new_spec))
151+
152+
dependencies._value[idx] = _add_date_comment(
153+
dependencies._value[idx], min_ver_release, next_ver, next_ver_release
154+
)
155+
dependencies[idx] = _prettify_requirement(req)
156+
return dependencies
157+
158+
159+
def _as_minor_version(ver):
160+
"""Convert a version to its major.minor form."""
161+
return Version(f"{ver.major}.{ver.minor}")
162+
163+
164+
def _find_version_to_pin_and_release(version, specifier, all_versions, release_dates):
165+
"""Find the version to pin based on specifiers and that version's release date.
166+
167+
Tries to reduce this to an unpatched major.minor form if possible (and use the
168+
unpatched version's release date). If the unpatched minor form is excluded by the
169+
specifier, finds the earliest patch version (and its release date) that is not
170+
excluded.
171+
172+
E.g., if version=1.2.3 but 1.2.0 is excluded by the specifier, find the earliest
173+
patched version (e.g., 1.2.1) and pin this. If 1.2.0 is not excluded, we can just
174+
pin 1.2.
175+
176+
If the unpatched version is not excluded by the specifier but it has been yanked, we
177+
don't need to pin the patched version, but we do have to rely on the release date of
178+
the earliest patched version.
179+
"""
180+
# Find earliest micro form of this minor version
181+
version = min(
182+
ver
183+
for ver in all_versions
184+
if _as_minor_version(ver) == _as_minor_version(version)
185+
)
186+
# Check unpatched form of this version is not excluded by existing specifiers
187+
use_patch = not specifier.contains(_as_minor_version(version))
188+
# Find release date of version to be pinned
189+
release = release_dates[version]["release_date"]
190+
# Discard patch info if not needed
191+
version = _as_minor_version(version) if not use_patch else version
192+
193+
return version, release
194+
195+
196+
def _prettify_requirement(req):
197+
"""Add spacing to make a requirement specifier prettier."""
198+
specifiers = []
199+
spec_order = _find_specifier_order(req.specifier)
200+
for spec in req.specifier:
201+
spec = str(spec)
202+
split = re.search(r"[<>=]\d", spec).span()[1] - 1 # find end of operator
203+
specifiers.append(f" {spec[:split]} {spec[split:]},") # pad operator w/ spaces
204+
specifiers = [specifiers[i] for i in spec_order] # order by ascending version
205+
specifiers = "".join(specifiers)
206+
specifiers = specifiers.rstrip(",") # remove trailing comma
207+
req.specifier = SpecifierSet() # remove ugly specifiers (from str repr)
208+
# Add pretty specifiers to name alongside trailing info (extras, markers, url)
209+
return req.name + specifiers + str(req)[len(req.name) :]
210+
211+
212+
def _add_date_comment(dependency, min_ver_release, next_ver, next_ver_release):
213+
"""Add comment for when the min version was released and when it will be changed."""
214+
comment = f"# released {min_ver_release.strftime('%Y-%m-%d')}"
215+
if next_ver is not None:
216+
comment += (
217+
f", will become {str(next_ver)} on "
218+
f"{(next_ver_release + SUPPORT_TIME).strftime('%Y-%m-%d')}"
219+
)
220+
else:
221+
comment += ", no newer version available"
222+
dependency.comment = Comment(
223+
Trivia(indent=" ", comment_ws="", comment=comment, trail="")
224+
)
225+
return dependency
226+
227+
228+
def _find_specifier_order(specifiers):
229+
"""Find ascending order of specifiers according to their version."""
230+
versions = []
231+
for spec in specifiers:
232+
versions.append(Version(re.sub(r"[<>=!~]+", "", str(spec)))) # extract version
233+
return sorted(range(len(versions)), key=lambda i: versions[i]) # sorted indices
234+
235+
236+
# Find release and drop dates for desired packages
237+
package_releases = {
238+
package: get_release_and_drop_dates(package) for package in SORT_PACKAGES
239+
}
240+
241+
# Get dependencies from pyproject.toml
242+
pyproject = TOMLFile("pyproject.toml")
243+
pyproject_data = pyproject.read()
244+
project_info = pyproject_data.get("project")
245+
core_dependencies = project_info["dependencies"]
246+
opt_dependencies = project_info.get("optional-dependencies", {})
247+
248+
# Update version specifiers
249+
core_dependencies = update_specifiers(core_dependencies, package_releases)
250+
for key in opt_dependencies:
251+
opt_dependencies[key] = update_specifiers(opt_dependencies[key], package_releases)
252+
pyproject_data["project"]["dependencies"] = core_dependencies
253+
if opt_dependencies:
254+
pyproject_data["project"]["optional-dependencies"] = opt_dependencies
255+
256+
# Save updated pyproject.toml (replace ugly \" with ' first)
257+
pyproject_data = parse(pyproject_data.as_string().replace('\\"', "'"))
258+
pyproject.write(pyproject_data)

0 commit comments

Comments
 (0)