Skip to content

Commit f96b79a

Browse files
committed
Have you ever wondered where your life went wrong?
1 parent 9d2dd02 commit f96b79a

6 files changed

Lines changed: 373 additions & 344 deletions

File tree

readme.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ jobs:
3030
update:
3131
runs-on: ubuntu-latest
3232
steps:
33-
- uses: scientific-python/spec0-action@8b8b76f254aecce36e6f07de7dde174cb3cafa81 # v1.3
33+
- uses: scientific-python/spec0-action@8b8b76f254aecce36e6f07de7dde174cb3cafa81 # v1.3
3434
with:
35-
update_all: 2 # also bump non-SPEC0 deps older than 2 years
35+
update_all: 2 # also bump non-SPEC0 deps older than 2 years
3636
```
3737
3838
No PAT required.

spec0_action/__init__.py

Lines changed: 100 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import contextlib
2-
from collections import defaultdict
1+
from functools import cache
32
from packaging.specifiers import SpecifierSet
4-
from typing import Sequence, Dict
3+
from typing import Callable, Sequence, Dict
54
import datetime
65
import requests
76

@@ -28,6 +27,7 @@
2827
__all__ = ["read_schedule", "read_toml", "write_toml", "update_pyproject_toml"]
2928

3029

30+
@cache
3131
def _get_oldest_version_in_window(package: str, years: float) -> Version | None:
3232
"""
3333
Query PyPI, return oldest non-pre release version uploaded within the last ``years`` years.
@@ -45,28 +45,23 @@ def _get_oldest_version_in_window(package: str, years: float) -> Version | None:
4545
data = resp.json()
4646
except Exception:
4747
return None
48-
release_dates: dict[Version, list[datetime.datetime]] = defaultdict(list)
48+
first_uploads: dict[Version, datetime.datetime] = {}
4949
for f in data.get("files", []):
5050
ver = _version_from_filename(f.get("filename", ""))
5151
if ver is None or ver.is_prerelease:
5252
continue
5353

54-
upload_str = f.get("upload-time", "")
55-
upload_time = None
56-
for fmt in ["%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"]:
57-
with contextlib.suppress(ValueError):
58-
upload_time = datetime.datetime.strptime(upload_str, fmt).replace(
59-
tzinfo=datetime.timezone.utc
60-
)
61-
break
62-
if upload_time is None:
54+
try:
55+
upload_time = datetime.datetime.fromisoformat(f.get("upload-time", ""))
56+
except ValueError:
6357
continue
64-
release_dates[ver].append(upload_time)
58+
59+
previous = first_uploads.get(ver)
60+
if previous is None or upload_time < previous:
61+
first_uploads[ver] = upload_time
6562

6663
candidates = [
67-
ver
68-
for ver, upload_times in release_dates.items()
69-
if min(upload_times) >= cutoff
64+
ver for ver, first_upload in first_uploads.items() if first_upload >= cutoff
7065
]
7166
return min(candidates, default=None)
7267

@@ -86,105 +81,103 @@ def _version_from_filename(filename: str) -> Version | None:
8681

8782

8883
def update_pyproject_dependencies(
89-
dependencies: list, schedule: Dict[str, str], own_name: str | None = None
84+
dependencies: list,
85+
resolve_lower_bound: Callable[[str], Version | None],
86+
own_name: str | None,
9087
):
9188
# Iterate by idx because we want to update it inplace
92-
for i in range(len(dependencies)):
93-
dep_str = dependencies[i]
89+
for i, dep_str in enumerate(dependencies):
9490
if not isinstance(dep_str, str):
9591
continue
9692
pkg, extras, spec, env = parse_pep_dependency(dep_str)
97-
schedule_key = canonicalize_name(pkg)
98-
if (
99-
isinstance(spec, Url)
100-
or schedule_key == own_name
101-
or schedule_key not in schedule
102-
):
93+
package_key = canonicalize_name(pkg)
94+
if isinstance(spec, Url) or package_key == own_name:
10395
continue
104-
new_lower_bound = Version(schedule[schedule_key])
105-
try:
106-
spec = tighten_lower_bound(spec or SpecifierSet(), new_lower_bound)
107-
# Will raise a value error if bound is already tighter, in this case we just do nothing and continue
108-
except ValueError:
96+
new_lower_bound = resolve_lower_bound(package_key)
97+
if new_lower_bound is None:
10998
continue
110-
if not extras:
111-
new_dep_str = f"{pkg}{repr_spec_set(spec)}{env or ''}"
112-
else:
113-
new_dep_str = f"{pkg}{extras}{repr_spec_set(spec)}{env or ''}"
114-
dependencies[i] = new_dep_str
115-
116-
117-
def iter_pep_dependency_lists(pyproject_data: dict, project_data: dict):
118-
dependencies = project_data.get("dependencies")
119-
if isinstance(dependencies, list):
120-
yield dependencies
99+
new_spec = tighten_lower_bound(spec or SpecifierSet(), new_lower_bound)
100+
if new_spec is None or new_spec == spec:
101+
# The new bound doesn't fit the existing spec or changes nothing
102+
continue
103+
dependencies[i] = f"{pkg}{extras or ''}{repr_spec_set(new_spec)}{env or ''}"
121104

122-
optional_dependencies = project_data.get("optional-dependencies", {})
123-
if isinstance(optional_dependencies, dict):
124-
for dependencies in optional_dependencies.values():
125-
if isinstance(dependencies, list):
126-
yield dependencies
127105

128-
dependency_groups = pyproject_data.get("dependency-groups", {})
129-
if isinstance(dependency_groups, dict):
130-
for dependencies in dependency_groups.values():
131-
if isinstance(dependencies, list):
132-
yield dependencies
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))
133117

134118

135119
def update_dependency_table(
136-
dep_table: dict, new_versions: dict, own_name: str | None = None
120+
dep_table: dict, new_versions: Dict[str, Version], own_name: str | None
137121
):
138122
for pkg, pkg_data in dep_table.items():
139-
schedule_key = canonicalize_name(pkg)
123+
package_key = canonicalize_name(pkg)
140124
# Don't do anything for the package itself or pkgs that aren't in our schedule
141-
if schedule_key == own_name or schedule_key not in new_versions:
125+
if package_key == own_name or package_key not in new_versions:
142126
continue
143127
# Like pkg = ">x.y.z,<a"
144128
if isinstance(pkg_data, str):
145-
if not is_url_spec(pkg_data):
146-
spec = parse_version_spec(pkg_data)
147-
new_lower_bound = Version(new_versions[schedule_key])
148-
try:
149-
spec = tighten_lower_bound(spec, new_lower_bound)
150-
except ValueError:
151-
continue
152-
dep_table[pkg] = repr_spec_set(spec)
153-
else:
129+
if is_url_spec(pkg_data):
154130
# We don't do anything with url spec dependencies
155131
continue
132+
spec_str = pkg_data
133+
elif isinstance(pkg_data, dict) and "version" in pkg_data:
134+
# Table like pkg = {version = ">x.y.z", ...}
135+
spec_str = pkg_data["version"]
156136
else:
157-
# Table like in tests = {path = "."}
158-
if not isinstance(pkg_data, dict) or "version" not in pkg_data:
159-
# We don't do anything with path, url, git, or other non-version dependencies
160-
continue
161-
spec = parse_version_spec(pkg_data["version"])
162-
new_lower_bound = Version(new_versions[schedule_key])
163-
try:
164-
spec = tighten_lower_bound(spec, new_lower_bound)
165-
except ValueError:
166-
continue
167-
pkg_data["version"] = repr_spec_set(spec)
137+
# We don't do anything with path, url, git, or other non-version dependencies
138+
continue
139+
current_spec = parse_version_spec(spec_str)
140+
new_spec = tighten_lower_bound(current_spec, new_versions[package_key])
141+
if new_spec is None or new_spec == current_spec:
142+
continue
143+
if isinstance(pkg_data, str):
144+
dep_table[pkg] = repr_spec_set(new_spec)
145+
else:
146+
pkg_data["version"] = repr_spec_set(new_spec)
168147

169148

170149
def update_pixi_dependencies(
171-
pixi_tables: dict, schedule: Dict[str, str], own_name: str | None = None
150+
pixi_tables: dict, new_versions: Dict[str, Version], own_name: str | None
172151
):
173-
if "pypi-dependencies" in pixi_tables:
174-
update_dependency_table(pixi_tables["pypi-dependencies"], schedule, own_name)
175-
if "dependencies" in pixi_tables:
176-
update_dependency_table(pixi_tables["dependencies"], schedule, own_name)
177-
178-
if "feature" in pixi_tables:
179-
for _, feature_data in pixi_tables["feature"].items():
180-
if "dependencies" in feature_data:
181-
update_dependency_table(
182-
feature_data["dependencies"], schedule, own_name
183-
)
184-
if "pypi-dependencies" in feature_data:
185-
update_dependency_table(
186-
feature_data["pypi-dependencies"], schedule, own_name
187-
)
152+
for key in ("dependencies", "pypi-dependencies"):
153+
dep_table = pixi_tables.get(key)
154+
if isinstance(dep_table, dict):
155+
update_dependency_table(dep_table, new_versions, own_name)
156+
157+
# Recurse into [tool.pixi.feature.X] and platform tables like
158+
# [tool.pixi.target.linux-64], which hold the same dependency keys
159+
for key in ("feature", "target"):
160+
subtables = pixi_tables.get(key)
161+
if isinstance(subtables, dict):
162+
for subtable in subtables.values():
163+
if isinstance(subtable, dict):
164+
update_pixi_dependencies(subtable, new_versions, own_name)
165+
166+
167+
def _update_requires_python(project_data: dict, new_lower_bound: Version):
168+
current_requires_python = project_data.get("requires-python")
169+
if not current_requires_python:
170+
project_data["requires-python"] = f">={new_lower_bound}"
171+
return
172+
try:
173+
current_spec = parse_version_spec(current_requires_python)
174+
except ValueError:
175+
# Leave specs we can't parse (e.g. poetry-style "^3.10") alone
176+
return
177+
new_spec = tighten_lower_bound(current_spec, new_lower_bound)
178+
if new_spec is not None and new_spec != current_spec:
179+
# Only write when the bound actually moved, to avoid cosmetic rewrites
180+
project_data["requires-python"] = repr_spec_set(new_spec)
188181

189182

190183
def update_pyproject_toml(
@@ -200,11 +193,11 @@ def update_pyproject_toml(
200193
),
201194
key=lambda s: datetime.datetime.fromisoformat(s["start_date"]),
202195
)
203-
new_version = {}
196+
new_version: Dict[str, Version] = {}
204197
for schedule in applicable:
205198
# Fill in the latest known requirement (schedule is sorted, newer entries overwrite older)
206199
for pkg, version in schedule["packages"].items():
207-
new_version[canonicalize_name(pkg)] = version
200+
new_version[canonicalize_name(pkg)] = Version(version)
208201
if not new_version:
209202
raise RuntimeError(
210203
"Could not find schedule that applies to current time, perhaps your schedule is outdated."
@@ -215,47 +208,20 @@ def update_pyproject_toml(
215208
# Self-references like "pkg[extras]" are used to share extras between
216209
# dependency groups, their version is always the local one so never pin it.
217210
own_name = project_data.get("name")
218-
if isinstance(own_name, str):
219-
own_name = canonicalize_name(own_name)
220-
else:
221-
own_name = None
222-
if "python" in new_version and isinstance(project_data, dict):
223-
current_requires_python = project_data.get("requires-python")
224-
if current_requires_python:
225-
try:
226-
python_spec = tighten_lower_bound(
227-
parse_version_spec(current_requires_python),
228-
Version(new_version["python"]),
229-
)
230-
except ValueError:
231-
python_spec = parse_version_spec(current_requires_python)
232-
else:
233-
python_spec = parse_version_spec(new_version["python"])
234-
project_data["requires-python"] = repr_spec_set(python_spec)
211+
own_name = canonicalize_name(own_name) if isinstance(own_name, str) else None
212+
213+
if "python" in new_version:
214+
_update_requires_python(project_data, new_version["python"])
215+
216+
def resolve_lower_bound(package_key: str) -> Version | None:
217+
if package_key in new_version:
218+
return new_version[package_key]
219+
if update_all is not None:
220+
return _get_oldest_version_in_window(package_key, update_all)
221+
return None
235222

236-
for dependencies in iter_pep_dependency_lists(pyproject_data, project_data):
237-
update_pyproject_dependencies(dependencies, new_version, own_name)
223+
for dependencies in iter_pep_dependency_lists(pyproject_data):
224+
update_pyproject_dependencies(dependencies, resolve_lower_bound, own_name)
238225

239226
if "tool" in pyproject_data and "pixi" in pyproject_data["tool"]:
240-
pixi_data = pyproject_data["tool"]["pixi"]
241-
update_pixi_dependencies(pixi_data, new_version, own_name)
242-
if update_all is not None:
243-
for deps in iter_pep_dependency_lists(pyproject_data, project_data):
244-
for i, dep_str in enumerate(deps):
245-
if not isinstance(dep_str, str):
246-
continue
247-
pkg, extras, spec, env = parse_pep_dependency(dep_str)
248-
if (
249-
canonicalize_name(pkg) in new_version
250-
or canonicalize_name(pkg) == own_name
251-
or isinstance(spec, Url)
252-
):
253-
continue
254-
min_ver = _get_oldest_version_in_window(pkg, update_all)
255-
if min_ver is None:
256-
continue
257-
try:
258-
updated = tighten_lower_bound(spec or SpecifierSet(), min_ver)
259-
deps[i] = f"{pkg}{extras or ''}{repr_spec_set(updated)}{env or ''}"
260-
except ValueError:
261-
continue
227+
update_pixi_dependencies(pyproject_data["tool"]["pixi"], new_version, own_name)

spec0_action/versions.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,28 @@
44

55
def tighten_lower_bound(
66
spec_set: SpecifierSet, new_lower_bound: Version
7-
) -> SpecifierSet:
7+
) -> SpecifierSet | None:
8+
"""
9+
Return ``spec_set`` with its lower bound raised to ``new_lower_bound``.
10+
11+
Returns None when the new bound does not satisfy ``spec_set`` (the existing
12+
bounds are already tighter or conflict with it); callers should then leave
13+
the original spec alone.
14+
"""
815
if new_lower_bound not in spec_set:
9-
raise ValueError(f"{new_lower_bound} does not satisfy {spec_set}")
16+
return None
1017

1118
out = []
1219
contains_lower_bound = False
1320

1421
for spec in spec_set:
15-
if spec.operator not in [">", ">="]:
16-
out.append(spec)
17-
continue
18-
if new_lower_bound in spec:
22+
if spec.operator in (">", ">="):
23+
# new_lower_bound satisfies every specifier in the set, so it can
24+
# simply replace any existing lower bound
1925
out.append(Specifier(f">={new_lower_bound}"))
2026
contains_lower_bound = True
2127
else:
22-
raise ValueError(f"{spec} is already stricter than {new_lower_bound}")
28+
out.append(spec)
2329

2430
if not contains_lower_bound:
2531
out.append(Specifier(f">={new_lower_bound}"))

0 commit comments

Comments
 (0)