Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,26 +58,32 @@ runs:
fileName: "schedule.json"
- name: Run update script
shell: bash
env:
PROJECT_FILE_NAME: ${{ inputs.project_file_name }}
SCHEDULE_INPUT: ${{ inputs.schedule_path }}
UPDATE_ALL: ${{ inputs.update_all }}
run: |
set -e
if [ -n "${{ inputs.schedule_path }}" ]; then
SCHEDULE_PATH="${{ github.workspace }}/${{ inputs.schedule_path }}"
if [ -n "$SCHEDULE_INPUT" ]; then
SCHEDULE_PATH="${GITHUB_WORKSPACE}/${SCHEDULE_INPUT}"
else
SCHEDULE_PATH="${{ github.workspace }}/schedule.json"
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 }}"
echo "Updating ${PROJECT_FILE_NAME} using schedule ${SCHEDULE_PATH}"
UPDATE_ALL_ARGS=()
if [ -n "$UPDATE_ALL" ]; then
UPDATE_ALL_ARGS=(--update-all "$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
pixi run --manifest-path "${GITHUB_ACTION_PATH}/pyproject.toml" update-dependencies "${GITHUB_WORKSPACE}/${PROJECT_FILE_NAME}" "$SCHEDULE_PATH" "${UPDATE_ALL_ARGS[@]}"
- name: Changes
id: changes
shell: bash
env:
PROJECT_FILE_NAME: ${{ inputs.project_file_name }}
run: |
echo "Showing changes that would be committed"
git --no-pager diff ${{ inputs.project_file_name }}
if git diff --quiet ${{ inputs.project_file_name }}; then
git --no-pager diff -- "$PROJECT_FILE_NAME"
if git diff --quiet -- "$PROJECT_FILE_NAME"; then
echo "changes_detected=false" >> "$GITHUB_OUTPUT"
else
echo "changes_detected=true" >> "$GITHUB_OUTPUT"
Expand Down
3 changes: 1 addition & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ name = "spec0-action"
description = "Python code to update the lower bounds of Scientific Python libraries according to SPEC 0"
requires-python = ">= 3.11"
version = "1.0.0"
dependencies = ["packaging>=25.0", "pandas>=2.3.3", "requests>=2.32.5,<3"]
dependencies = ["packaging>=25.0", "pandas>=2.3.3", "requests>=2.32.5,<3", "tomlkit>=0.13.3,<0.14"]

[build-system]
build-backend = "hatchling.build"
Expand All @@ -16,7 +16,6 @@ platforms = ["linux-64"]

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

[tool.pixi.tasks]
update-dependencies = { cmd = ["python", "run_spec0_update.py"] }
Expand Down
8 changes: 7 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,17 @@ permissions:
contents: write
pull-requests: write

concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true

jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: scientific-python/spec0-action@v1
- uses: scientific-python/spec0-action@8b8b76f254aecce36e6f07de7dde174cb3cafa81 # v1.3
with:
update_all: 2 # also bump non-SPEC0 deps older than 2 years
```
No PAT required.
Expand Down
230 changes: 143 additions & 87 deletions spec0_action/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import contextlib
from functools import cache
from packaging.specifiers import SpecifierSet
from typing import Sequence, Dict
from typing import Callable, Sequence, Dict
import datetime
import requests

Expand All @@ -15,11 +15,19 @@
read_toml,
write_toml,
)
from packaging.version import Version, InvalidVersion
from packaging.version import Version
from packaging.utils import (
InvalidSdistFilename,
InvalidWheelFilename,
canonicalize_name,
parse_sdist_filename,
parse_wheel_filename,
)

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


@cache
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.
Expand All @@ -37,88 +45,137 @@ def _get_oldest_version_in_window(package: str, years: float) -> Version | None:
data = resp.json()
except Exception:
return None
candidates: list[Version] = []
first_uploads: dict[Version, datetime.datetime] = {}
for f in data.get("files", []):
parts = f.get("filename", "").split("-")
if len(parts) < 2:
ver = _version_from_filename(f.get("filename", ""))
if ver is None or ver.is_prerelease:
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:
upload_time = datetime.datetime.fromisoformat(f.get("upload-time", ""))
except ValueError:
continue
candidates.append(ver)

previous = first_uploads.get(ver)
if previous is None or upload_time < previous:
first_uploads[ver] = upload_time

candidates = [
ver for ver, first_upload in first_uploads.items() if first_upload >= cutoff
]
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]
def _version_from_filename(filename: str) -> Version | None:
try:
_, version, _, _ = parse_wheel_filename(filename)
return version
except InvalidWheelFilename:
pass

try:
_, version = parse_sdist_filename(filename)
return version
except InvalidSdistFilename:
return None


def update_pyproject_dependencies(
dependencies: list,
resolve_lower_bound: Callable[[str], Version | None],
own_name: str | None,
):
# Assign by index so the (tomlkit) list is updated in place
for i, dep_str in enumerate(dependencies):
if not isinstance(dep_str, str):
continue
pkg, extras, spec, env = parse_pep_dependency(dep_str)
if isinstance(spec, Url) or pkg not in schedule:
package_key = canonicalize_name(pkg)
if isinstance(spec, Url) or package_key == own_name:
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:
new_lower_bound = resolve_lower_bound(package_key)
if new_lower_bound is None:
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
new_spec = tighten_lower_bound(spec or SpecifierSet(), new_lower_bound)
if new_spec is None or new_spec == spec:
# Skip no-op updates so unchanged specs keep their original formatting
continue
dependencies[i] = f"{pkg}{extras or ''}{repr_spec_set(new_spec)}{env or ''}"


def iter_pep_dependency_lists(pyproject_data: dict):
project_data = pyproject_data.get("project")
project_data = project_data if isinstance(project_data, dict) else {}
groups = [project_data.get("dependencies")]
for table in (
project_data.get("optional-dependencies"),
pyproject_data.get("dependency-groups"),
):
if isinstance(table, dict):
groups.extend(table.values())
yield from (group for group in groups if isinstance(group, list))


def update_dependency_table(dep_table: dict, new_versions: dict):
def update_dependency_table(
dep_table: dict, new_versions: Dict[str, Version], own_name: str | None
):
for pkg, pkg_data in dep_table.items():
# Don't do anything for pkgs that aren't in our schedule
if pkg not in new_versions:
package_key = canonicalize_name(pkg)
if package_key == own_name or package_key not in new_versions:
continue
# Like pkg = ">x.y.z,<a"
if isinstance(pkg_data, str):
if not is_url_spec(pkg_data):
spec = parse_version_spec(pkg_data)
new_lower_bound = Version(new_versions[pkg])
spec = tighten_lower_bound(spec, new_lower_bound)
dep_table[pkg] = repr_spec_set(spec)
else:
# We don't do anything with url spec dependencies
if is_url_spec(pkg_data):
continue
spec_str = pkg_data
elif isinstance(pkg_data, dict) and "version" in pkg_data:
# Table like pkg = {version = ">x.y.z", ...}
spec_str = pkg_data["version"]
else:
# Table like in tests = {path = "."}
if "path" in pkg_data:
# We don't do anything with path dependencies
continue
spec = SpecifierSet(pkg_data["version"])
new_lower_bound = Version(new_versions[pkg])
spec = tighten_lower_bound(spec, new_lower_bound)
pkg_data["version"] = repr_spec_set(spec)
# We don't do anything with path, url, git, or other non-version dependencies
continue
current_spec = parse_version_spec(spec_str)
new_spec = tighten_lower_bound(current_spec, new_versions[package_key])
if new_spec is None or new_spec == current_spec:
continue
if isinstance(pkg_data, str):
dep_table[pkg] = repr_spec_set(new_spec)
else:
pkg_data["version"] = repr_spec_set(new_spec)


def update_pixi_dependencies(
pixi_tables: dict, new_versions: Dict[str, Version], own_name: str | None
):
for key in ("dependencies", "pypi-dependencies"):
dep_table = pixi_tables.get(key)
if isinstance(dep_table, dict):
update_dependency_table(dep_table, new_versions, own_name)

def update_pixi_dependencies(pixi_tables: dict, schedule: Dict[str, str]):
if "pypi-dependencies" in pixi_tables:
update_dependency_table(pixi_tables["pypi-dependencies"], schedule)
if "dependencies" in pixi_tables:
update_dependency_table(pixi_tables["dependencies"], schedule)
# Recurse into [tool.pixi.feature.X] and platform tables like
# [tool.pixi.target.linux-64], which hold the same dependency keys
for key in ("feature", "target"):
subtables = pixi_tables.get(key)
if isinstance(subtables, dict):
for subtable in subtables.values():
if isinstance(subtable, dict):
update_pixi_dependencies(subtable, new_versions, own_name)

if "feature" in pixi_tables:
for _, feature_data in pixi_tables["feature"].items():
if "dependencies" in feature_data:
update_dependency_table(feature_data["dependencies"], schedule)

def _update_requires_python(project_data: dict, new_lower_bound: Version):
current_requires_python = project_data.get("requires-python")
if not current_requires_python:
project_data["requires-python"] = f">={new_lower_bound}"
return
try:
current_spec = parse_version_spec(current_requires_python)
except ValueError:
# Leave specs we can't parse (e.g. poetry-style "^3.10") alone
return
new_spec = tighten_lower_bound(current_spec, new_lower_bound)
if new_spec is not None and new_spec != current_spec:
# Only write when the bound actually moved, to avoid cosmetic rewrites
project_data["requires-python"] = repr_spec_set(new_spec)


def update_pyproject_toml(
Expand All @@ -134,36 +191,35 @@ def update_pyproject_toml(
),
key=lambda s: datetime.datetime.fromisoformat(s["start_date"]),
)
new_version = {}
new_version: Dict[str, Version] = {}
for schedule in applicable:
# Fill in the latest known requirement (schedule is sorted, newer entries overwrite older)
for pkg, version in schedule["packages"].items():
new_version[pkg] = version
new_version[canonicalize_name(pkg)] = Version(version)
if not new_version:
raise RuntimeError(
"Could not find schedule that applies to current time, perhaps your schedule is outdated."
)
project_data = pyproject_data.get("project", {})
if not isinstance(project_data, dict):
project_data = {}
# Self-references like "pkg[extras]" are used to share extras between
# dependency groups, their version is always the local one so never pin it.
own_name = project_data.get("name")
own_name = canonicalize_name(own_name) if isinstance(own_name, str) else None

if "python" in new_version:
pyproject_data["project"]["requires-python"] = repr_spec_set(
parse_version_spec(new_version["python"])
)
update_pyproject_dependencies(
pyproject_data["project"]["dependencies"], new_version
)
_update_requires_python(project_data, new_version["python"])

def resolve_lower_bound(package_key: str) -> Version | None:
if package_key in new_version:
return new_version[package_key]
if update_all is not None:
return _get_oldest_version_in_window(package_key, update_all)
return None

for dependencies in iter_pep_dependency_lists(pyproject_data):
update_pyproject_dependencies(dependencies, resolve_lower_bound, own_name)

if "tool" in pyproject_data and "pixi" in pyproject_data["tool"]:
pixi_data = pyproject_data["tool"]["pixi"]
update_pixi_dependencies(pixi_data, new_version)
if update_all is not None:
deps = pyproject_data.get("project", {}).get("dependencies", [])
for i, dep_str in enumerate(deps):
pkg, extras, spec, env = parse_pep_dependency(dep_str)
if pkg in new_version or isinstance(spec, Url) or spec is None:
continue
min_ver = _get_oldest_version_in_window(pkg, update_all)
if min_ver is None:
continue
try:
updated = tighten_lower_bound(spec, min_ver)
deps[i] = f"{pkg}{extras or ''}{repr_spec_set(updated)}{env or ''}"
except ValueError:
continue
update_pixi_dependencies(pyproject_data["tool"]["pixi"], new_version, own_name)
3 changes: 1 addition & 2 deletions spec0_action/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ def parse_version_spec(s: str) -> SpecifierSet:
# see https://packaging.python.org/en/latest/specifications/version-specifiers/
return SpecifierSet(">=0")
try:
# If we can simply parse it return it
return SpecifierSet(s)
except InvalidSpecifier:
try:
Expand All @@ -39,7 +38,7 @@ def parse_version_spec(s: str) -> SpecifierSet:
try:
return SpecifierSet(f"=={s}")
except InvalidVersion:
# if we don't return later raise is the same
# fall through to the raise below
pass

raise ValueError(f"{s} is not a version or specifyer")
Expand Down
Loading