Skip to content

Commit 6d7df27

Browse files
authored
Merge pull request #32 from scientific-python/wip
Bugfixes
2 parents d05186b + b42a28a commit 6d7df27

10 files changed

Lines changed: 555 additions & 232 deletions

File tree

action.yaml

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,26 +58,32 @@ runs:
5858
fileName: "schedule.json"
5959
- name: Run update script
6060
shell: bash
61+
env:
62+
PROJECT_FILE_NAME: ${{ inputs.project_file_name }}
63+
SCHEDULE_INPUT: ${{ inputs.schedule_path }}
64+
UPDATE_ALL: ${{ inputs.update_all }}
6165
run: |
6266
set -e
63-
if [ -n "${{ inputs.schedule_path }}" ]; then
64-
SCHEDULE_PATH="${{ github.workspace }}/${{ inputs.schedule_path }}"
67+
if [ -n "$SCHEDULE_INPUT" ]; then
68+
SCHEDULE_PATH="${GITHUB_WORKSPACE}/${SCHEDULE_INPUT}"
6569
else
66-
SCHEDULE_PATH="${{ github.workspace }}/schedule.json"
70+
SCHEDULE_PATH="${GITHUB_WORKSPACE}/schedule.json"
6771
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+
echo "Updating ${PROJECT_FILE_NAME} using schedule ${SCHEDULE_PATH}"
73+
UPDATE_ALL_ARGS=()
74+
if [ -n "$UPDATE_ALL" ]; then
75+
UPDATE_ALL_ARGS=(--update-all "$UPDATE_ALL")
7276
fi
73-
pixi run --manifest-path ${{ github.action_path }}/pyproject.toml update-dependencies "${{ github.workspace }}/${{ inputs.project_file_name }}" "$SCHEDULE_PATH" $UPDATE_ALL_FLAG
77+
pixi run --manifest-path "${GITHUB_ACTION_PATH}/pyproject.toml" update-dependencies "${GITHUB_WORKSPACE}/${PROJECT_FILE_NAME}" "$SCHEDULE_PATH" "${UPDATE_ALL_ARGS[@]}"
7478
- name: Changes
7579
id: changes
7680
shell: bash
81+
env:
82+
PROJECT_FILE_NAME: ${{ inputs.project_file_name }}
7783
run: |
7884
echo "Showing changes that would be committed"
79-
git --no-pager diff ${{ inputs.project_file_name }}
80-
if git diff --quiet ${{ inputs.project_file_name }}; then
85+
git --no-pager diff -- "$PROJECT_FILE_NAME"
86+
if git diff --quiet -- "$PROJECT_FILE_NAME"; then
8187
echo "changes_detected=false" >> "$GITHUB_OUTPUT"
8288
else
8389
echo "changes_detected=true" >> "$GITHUB_OUTPUT"

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ name = "spec0-action"
44
description = "Python code to update the lower bounds of Scientific Python libraries according to SPEC 0"
55
requires-python = ">= 3.11"
66
version = "1.0.0"
7-
dependencies = ["packaging>=25.0", "pandas>=2.3.3", "requests>=2.32.5,<3"]
7+
dependencies = ["packaging>=25.0", "pandas>=2.3.3", "requests>=2.32.5,<3", "tomlkit>=0.13.3,<0.14"]
88

99
[build-system]
1010
build-backend = "hatchling.build"
@@ -16,7 +16,6 @@ platforms = ["linux-64"]
1616

1717
[tool.pixi.pypi-dependencies]
1818
spec0-action= { path = ".", editable = true }
19-
tomlkit = ">=0.13.3,<0.14"
2019

2120
[tool.pixi.tasks]
2221
update-dependencies = { cmd = ["python", "run_spec0_update.py"] }

readme.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,17 @@ permissions:
2222
contents: write
2323
pull-requests: write
2424

25+
concurrency:
26+
group: ${{ github.workflow }}
27+
cancel-in-progress: true
28+
2529
jobs:
2630
update:
2731
runs-on: ubuntu-latest
2832
steps:
29-
- uses: scientific-python/spec0-action@v1
33+
- uses: scientific-python/spec0-action@8b8b76f254aecce36e6f07de7dde174cb3cafa81 # v1.3
34+
with:
35+
update_all: 2 # also bump non-SPEC0 deps older than 2 years
3036
```
3137
3238
No PAT required.

spec0_action/__init__.py

Lines changed: 143 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import contextlib
1+
from functools import cache
22
from packaging.specifiers import SpecifierSet
3-
from typing import Sequence, Dict
3+
from typing import Callable, Sequence, Dict
44
import datetime
55
import requests
66

@@ -15,11 +15,19 @@
1515
read_toml,
1616
write_toml,
1717
)
18-
from packaging.version import Version, InvalidVersion
18+
from packaging.version import Version
19+
from packaging.utils import (
20+
InvalidSdistFilename,
21+
InvalidWheelFilename,
22+
canonicalize_name,
23+
parse_sdist_filename,
24+
parse_wheel_filename,
25+
)
1926

2027
__all__ = ["read_schedule", "read_toml", "write_toml", "update_pyproject_toml"]
2128

2229

30+
@cache
2331
def _get_oldest_version_in_window(package: str, years: float) -> Version | None:
2432
"""
2533
Query PyPI, return oldest non-pre release version uploaded within the last ``years`` years.
@@ -37,88 +45,137 @@ def _get_oldest_version_in_window(package: str, years: float) -> Version | None:
3745
data = resp.json()
3846
except Exception:
3947
return None
40-
candidates: list[Version] = []
48+
first_uploads: dict[Version, datetime.datetime] = {}
4149
for f in data.get("files", []):
42-
parts = f.get("filename", "").split("-")
43-
if len(parts) < 2:
50+
ver = _version_from_filename(f.get("filename", ""))
51+
if ver is None or ver.is_prerelease:
4452
continue
53+
4554
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:
55+
upload_time = datetime.datetime.fromisoformat(f.get("upload-time", ""))
56+
except ValueError:
6057
continue
61-
candidates.append(ver)
6258

59+
previous = first_uploads.get(ver)
60+
if previous is None or upload_time < previous:
61+
first_uploads[ver] = upload_time
62+
63+
candidates = [
64+
ver for ver, first_upload in first_uploads.items() if first_upload >= cutoff
65+
]
6366
return min(candidates, default=None)
6467

6568

66-
def update_pyproject_dependencies(dependencies: dict, schedule: Dict[str, str]):
67-
# Iterate by idx because we want to update it inplace
68-
for i in range(len(dependencies)):
69-
dep_str = dependencies[i]
69+
def _version_from_filename(filename: str) -> Version | None:
70+
try:
71+
_, version, _, _ = parse_wheel_filename(filename)
72+
return version
73+
except InvalidWheelFilename:
74+
pass
75+
76+
try:
77+
_, version = parse_sdist_filename(filename)
78+
return version
79+
except InvalidSdistFilename:
80+
return None
81+
82+
83+
def update_pyproject_dependencies(
84+
dependencies: list,
85+
resolve_lower_bound: Callable[[str], Version | None],
86+
own_name: str | None,
87+
):
88+
# Assign by index so the (tomlkit) list is updated in place
89+
for i, dep_str in enumerate(dependencies):
90+
if not isinstance(dep_str, str):
91+
continue
7092
pkg, extras, spec, env = parse_pep_dependency(dep_str)
71-
if isinstance(spec, Url) or pkg not in schedule:
93+
package_key = canonicalize_name(pkg)
94+
if isinstance(spec, Url) or package_key == own_name:
7295
continue
73-
new_lower_bound = Version(schedule[pkg])
74-
try:
75-
spec = tighten_lower_bound(spec or SpecifierSet(), new_lower_bound)
76-
# Will raise a value error if bound is already tighter, in this case we just do nothing and continue
77-
except ValueError:
96+
new_lower_bound = resolve_lower_bound(package_key)
97+
if new_lower_bound is None:
7898
continue
79-
if not extras:
80-
new_dep_str = f"{pkg}{repr_spec_set(spec)}{env or ''}"
81-
else:
82-
new_dep_str = f"{pkg}{extras}{repr_spec_set(spec)}{env or ''}"
83-
dependencies[i] = new_dep_str
99+
new_spec = tighten_lower_bound(spec or SpecifierSet(), new_lower_bound)
100+
if new_spec is None or new_spec == spec:
101+
# Skip no-op updates so unchanged specs keep their original formatting
102+
continue
103+
dependencies[i] = f"{pkg}{extras or ''}{repr_spec_set(new_spec)}{env or ''}"
104+
105+
106+
def iter_pep_dependency_lists(pyproject_data: dict):
107+
project_data = pyproject_data.get("project")
108+
project_data = project_data if isinstance(project_data, dict) else {}
109+
groups = [project_data.get("dependencies")]
110+
for table in (
111+
project_data.get("optional-dependencies"),
112+
pyproject_data.get("dependency-groups"),
113+
):
114+
if isinstance(table, dict):
115+
groups.extend(table.values())
116+
yield from (group for group in groups if isinstance(group, list))
84117

85118

86-
def update_dependency_table(dep_table: dict, new_versions: dict):
119+
def update_dependency_table(
120+
dep_table: dict, new_versions: Dict[str, Version], own_name: str | None
121+
):
87122
for pkg, pkg_data in dep_table.items():
88-
# Don't do anything for pkgs that aren't in our schedule
89-
if pkg not in new_versions:
123+
package_key = canonicalize_name(pkg)
124+
if package_key == own_name or package_key not in new_versions:
90125
continue
91126
# Like pkg = ">x.y.z,<a"
92127
if isinstance(pkg_data, str):
93-
if not is_url_spec(pkg_data):
94-
spec = parse_version_spec(pkg_data)
95-
new_lower_bound = Version(new_versions[pkg])
96-
spec = tighten_lower_bound(spec, new_lower_bound)
97-
dep_table[pkg] = repr_spec_set(spec)
98-
else:
99-
# We don't do anything with url spec dependencies
128+
if is_url_spec(pkg_data):
100129
continue
130+
spec_str = pkg_data
131+
elif isinstance(pkg_data, dict) and "version" in pkg_data:
132+
# Table like pkg = {version = ">x.y.z", ...}
133+
spec_str = pkg_data["version"]
101134
else:
102-
# Table like in tests = {path = "."}
103-
if "path" in pkg_data:
104-
# We don't do anything with path dependencies
105-
continue
106-
spec = SpecifierSet(pkg_data["version"])
107-
new_lower_bound = Version(new_versions[pkg])
108-
spec = tighten_lower_bound(spec, new_lower_bound)
109-
pkg_data["version"] = repr_spec_set(spec)
135+
# We don't do anything with path, url, git, or other non-version dependencies
136+
continue
137+
current_spec = parse_version_spec(spec_str)
138+
new_spec = tighten_lower_bound(current_spec, new_versions[package_key])
139+
if new_spec is None or new_spec == current_spec:
140+
continue
141+
if isinstance(pkg_data, str):
142+
dep_table[pkg] = repr_spec_set(new_spec)
143+
else:
144+
pkg_data["version"] = repr_spec_set(new_spec)
145+
110146

147+
def update_pixi_dependencies(
148+
pixi_tables: dict, new_versions: Dict[str, Version], own_name: str | None
149+
):
150+
for key in ("dependencies", "pypi-dependencies"):
151+
dep_table = pixi_tables.get(key)
152+
if isinstance(dep_table, dict):
153+
update_dependency_table(dep_table, new_versions, own_name)
111154

112-
def update_pixi_dependencies(pixi_tables: dict, schedule: Dict[str, str]):
113-
if "pypi-dependencies" in pixi_tables:
114-
update_dependency_table(pixi_tables["pypi-dependencies"], schedule)
115-
if "dependencies" in pixi_tables:
116-
update_dependency_table(pixi_tables["dependencies"], schedule)
155+
# Recurse into [tool.pixi.feature.X] and platform tables like
156+
# [tool.pixi.target.linux-64], which hold the same dependency keys
157+
for key in ("feature", "target"):
158+
subtables = pixi_tables.get(key)
159+
if isinstance(subtables, dict):
160+
for subtable in subtables.values():
161+
if isinstance(subtable, dict):
162+
update_pixi_dependencies(subtable, new_versions, own_name)
117163

118-
if "feature" in pixi_tables:
119-
for _, feature_data in pixi_tables["feature"].items():
120-
if "dependencies" in feature_data:
121-
update_dependency_table(feature_data["dependencies"], schedule)
164+
165+
def _update_requires_python(project_data: dict, new_lower_bound: Version):
166+
current_requires_python = project_data.get("requires-python")
167+
if not current_requires_python:
168+
project_data["requires-python"] = f">={new_lower_bound}"
169+
return
170+
try:
171+
current_spec = parse_version_spec(current_requires_python)
172+
except ValueError:
173+
# Leave specs we can't parse (e.g. poetry-style "^3.10") alone
174+
return
175+
new_spec = tighten_lower_bound(current_spec, new_lower_bound)
176+
if new_spec is not None and new_spec != current_spec:
177+
# Only write when the bound actually moved, to avoid cosmetic rewrites
178+
project_data["requires-python"] = repr_spec_set(new_spec)
122179

123180

124181
def update_pyproject_toml(
@@ -134,36 +191,35 @@ def update_pyproject_toml(
134191
),
135192
key=lambda s: datetime.datetime.fromisoformat(s["start_date"]),
136193
)
137-
new_version = {}
194+
new_version: Dict[str, Version] = {}
138195
for schedule in applicable:
139196
# Fill in the latest known requirement (schedule is sorted, newer entries overwrite older)
140197
for pkg, version in schedule["packages"].items():
141-
new_version[pkg] = version
198+
new_version[canonicalize_name(pkg)] = Version(version)
142199
if not new_version:
143200
raise RuntimeError(
144201
"Could not find schedule that applies to current time, perhaps your schedule is outdated."
145202
)
203+
project_data = pyproject_data.get("project", {})
204+
if not isinstance(project_data, dict):
205+
project_data = {}
206+
# Self-references like "pkg[extras]" are used to share extras between
207+
# dependency groups, their version is always the local one so never pin it.
208+
own_name = project_data.get("name")
209+
own_name = canonicalize_name(own_name) if isinstance(own_name, str) else None
210+
146211
if "python" in new_version:
147-
pyproject_data["project"]["requires-python"] = repr_spec_set(
148-
parse_version_spec(new_version["python"])
149-
)
150-
update_pyproject_dependencies(
151-
pyproject_data["project"]["dependencies"], new_version
152-
)
212+
_update_requires_python(project_data, new_version["python"])
213+
214+
def resolve_lower_bound(package_key: str) -> Version | None:
215+
if package_key in new_version:
216+
return new_version[package_key]
217+
if update_all is not None:
218+
return _get_oldest_version_in_window(package_key, update_all)
219+
return None
220+
221+
for dependencies in iter_pep_dependency_lists(pyproject_data):
222+
update_pyproject_dependencies(dependencies, resolve_lower_bound, own_name)
223+
153224
if "tool" in pyproject_data and "pixi" in pyproject_data["tool"]:
154-
pixi_data = pyproject_data["tool"]["pixi"]
155-
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
225+
update_pixi_dependencies(pyproject_data["tool"]["pixi"], new_version, own_name)

spec0_action/parsing.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ def parse_version_spec(s: str) -> SpecifierSet:
2828
# see https://packaging.python.org/en/latest/specifications/version-specifiers/
2929
return SpecifierSet(">=0")
3030
try:
31-
# If we can simply parse it return it
3231
return SpecifierSet(s)
3332
except InvalidSpecifier:
3433
try:
@@ -39,7 +38,7 @@ def parse_version_spec(s: str) -> SpecifierSet:
3938
try:
4039
return SpecifierSet(f"=={s}")
4140
except InvalidVersion:
42-
# if we don't return later raise is the same
41+
# fall through to the raise below
4342
pass
4443

4544
raise ValueError(f"{s} is not a version or specifyer")

0 commit comments

Comments
 (0)