Skip to content
Draft
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
155 changes: 111 additions & 44 deletions spec0_action/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import contextlib
from collections import defaultdict
from packaging.specifiers import SpecifierSet
from typing import Sequence, Dict
import datetime
Expand All @@ -15,7 +16,14 @@
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"]

Expand All @@ -37,17 +45,12 @@ def _get_oldest_version_in_window(package: str, years: float) -> Version | None:
data = resp.json()
except Exception:
return None
candidates: list[Version] = []
release_dates: dict[Version, list[datetime.datetime]] = defaultdict(list)
for f in data.get("files", []):
parts = f.get("filename", "").split("-")
if len(parts) < 2:
continue
try:
ver = Version(parts[1])
except InvalidVersion:
continue
if ver.is_prerelease:
ver = _version_from_filename(f.get("filename", ""))
if ver is None or 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"]:
Expand All @@ -56,21 +59,43 @@ def _get_oldest_version_in_window(package: str, years: float) -> Version | None:
tzinfo=datetime.timezone.utc
)
break
if upload_time is None or upload_time < cutoff:
if upload_time is None:
continue
candidates.append(ver)
release_dates[ver].append(upload_time)

candidates = [
ver
for ver, upload_times in release_dates.items()
if min(upload_times) >= cutoff
]
return min(candidates, default=None)


def update_pyproject_dependencies(dependencies: dict, schedule: Dict[str, str]):
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, schedule: Dict[str, str]):
# Iterate by idx because we want to update it inplace
for i in range(len(dependencies)):
dep_str = dependencies[i]
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:
schedule_key = canonicalize_name(pkg)
if isinstance(spec, Url) or schedule_key not in schedule:
continue
new_lower_bound = Version(schedule[pkg])
new_lower_bound = Version(schedule[schedule_key])
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
Expand All @@ -83,29 +108,54 @@ def update_pyproject_dependencies(dependencies: dict, schedule: Dict[str, str]):
dependencies[i] = new_dep_str


def iter_pep_dependency_lists(pyproject_data: dict, project_data: dict):
dependencies = project_data.get("dependencies")
if isinstance(dependencies, list):
yield dependencies

optional_dependencies = project_data.get("optional-dependencies", {})
if isinstance(optional_dependencies, dict):
for dependencies in optional_dependencies.values():
if isinstance(dependencies, list):
yield dependencies

dependency_groups = pyproject_data.get("dependency-groups", {})
if isinstance(dependency_groups, dict):
for dependencies in dependency_groups.values():
if isinstance(dependencies, list):
yield dependencies


def update_dependency_table(dep_table: dict, new_versions: dict):
for pkg, pkg_data in dep_table.items():
schedule_key = canonicalize_name(pkg)
# Don't do anything for pkgs that aren't in our schedule
if pkg not in new_versions:
if schedule_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)
new_lower_bound = Version(new_versions[schedule_key])
try:
spec = tighten_lower_bound(spec, new_lower_bound)
except ValueError:
continue
dep_table[pkg] = repr_spec_set(spec)
else:
# We don't do anything with url spec dependencies
continue
else:
# Table like in tests = {path = "."}
if "path" in pkg_data:
# We don't do anything with path dependencies
if not isinstance(pkg_data, dict) or "version" not in pkg_data:
# We don't do anything with path, url, git, or other non-version dependencies
continue
spec = parse_version_spec(pkg_data["version"])
new_lower_bound = Version(new_versions[schedule_key])
try:
spec = tighten_lower_bound(spec, new_lower_bound)
except ValueError:
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)


Expand All @@ -119,6 +169,8 @@ def update_pixi_dependencies(pixi_tables: dict, schedule: Dict[str, str]):
for _, feature_data in pixi_tables["feature"].items():
if "dependencies" in feature_data:
update_dependency_table(feature_data["dependencies"], schedule)
if "pypi-dependencies" in feature_data:
update_dependency_table(feature_data["pypi-dependencies"], schedule)


def update_pyproject_toml(
Expand All @@ -138,32 +190,47 @@ def update_pyproject_toml(
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
if not new_version:
raise RuntimeError(
"Could not find schedule that applies to current time, perhaps your schedule is outdated."
)
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
)
project_data = pyproject_data.get("project", {})
if not isinstance(project_data, dict):
project_data = {}
if "python" in new_version and isinstance(project_data, dict):
current_requires_python = project_data.get("requires-python")
if current_requires_python:
try:
python_spec = tighten_lower_bound(
parse_version_spec(current_requires_python),
Version(new_version["python"]),
)
except ValueError:
python_spec = parse_version_spec(current_requires_python)
else:
python_spec = parse_version_spec(new_version["python"])
project_data["requires-python"] = repr_spec_set(python_spec)

for dependencies in iter_pep_dependency_lists(pyproject_data, project_data):
update_pyproject_dependencies(dependencies, new_version)

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
for deps in iter_pep_dependency_lists(pyproject_data, project_data):
for i, dep_str in enumerate(deps):
if not isinstance(dep_str, str):
continue
pkg, extras, spec, env = parse_pep_dependency(dep_str)
if canonicalize_name(pkg) in new_version or isinstance(spec, Url):
continue
min_ver = _get_oldest_version_in_window(pkg, update_all)
if min_ver is None:
continue
try:
updated = tighten_lower_bound(spec or SpecifierSet(), min_ver)
deps[i] = f"{pkg}{extras or ''}{repr_spec_set(updated)}{env or ''}"
except ValueError:
continue
3 changes: 3 additions & 0 deletions spec0_action/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
def tighten_lower_bound(
spec_set: SpecifierSet, new_lower_bound: Version
) -> SpecifierSet:
if new_lower_bound not in spec_set:
raise ValueError(f"{new_lower_bound} does not satisfy {spec_set}")

out = []
contains_lower_bound = False

Expand Down
2 changes: 1 addition & 1 deletion tests/test_data/pyproject_pixi_updated.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@ xarray = ">=2024.1.0"
bar = ["foo"]

[tool.pixi.dependencies]
numpy = ">=2.0.0,<2"
numpy = ">=1.10.0,<2"
Loading