From b29d9245c79e1e4250bbc7270844d0294e138350 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 3 Dec 2025 10:37:58 -0800 Subject: [PATCH 01/10] test ci on actions --- .github/workflows/test_action.yaml | 15 ++++++++++++++- .github/workflows/test_bench.yml | 10 ++++++---- run_spec0_update.py | 2 ++ 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test_action.yaml b/.github/workflows/test_action.yaml index 20f5075..df1f64f 100644 --- a/.github/workflows/test_action.yaml +++ b/.github/workflows/test_action.yaml @@ -1,5 +1,18 @@ name: Test Action -on: [push, pull_request] +on: + push: + branches: + - "main" + - "*.*" + tags: + - "v*" + pull_request: + # Allow manual runs through the web UI + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: generate_data: diff --git a/.github/workflows/test_bench.yml b/.github/workflows/test_bench.yml index 824679a..79e2b28 100644 --- a/.github/workflows/test_bench.yml +++ b/.github/workflows/test_bench.yml @@ -1,10 +1,13 @@ name: Run the update test suite on: push: - branches: main + branches: + - "main" + - "*.*" + tags: + - "v*" pull_request: - branches: main - # On demand + # Allow manual runs through the web UI workflow_dispatch: jobs: @@ -13,7 +16,6 @@ jobs: steps: - name: Checkout uses: actions/checkout@v6 - - uses: prefix-dev/setup-pixi@v0.9.3 with: pixi-version: "v0.49.0" diff --git a/run_spec0_update.py b/run_spec0_update.py index c03981e..00c629a 100644 --- a/run_spec0_update.py +++ b/run_spec0_update.py @@ -35,7 +35,9 @@ ) project_data = read_toml(toml_path) + print(project_data) schedule_data = read_schedule(schedule_path) + print(schedule_data) update_pyproject_toml(project_data, schedule_data) write_toml(toml_path, project_data) From dcaf6e819826003dbadc03520161a9a8e5dbf76d Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 3 Dec 2025 10:52:28 -0800 Subject: [PATCH 02/10] random tests --- run_spec0_update.py | 2 - spec0_action/__init__.py | 1 - tests/test_data/pyproject.toml | 42 +++++++-------------- tests/test_data/pyproject_pixi.toml | 30 +++++++++++++++ tests/test_data/pyproject_pixi_updated.toml | 30 +++++++++++++++ tests/test_data/pyproject_updated.toml | 42 +++++++-------------- tests/test_update_pyproject_toml.py | 9 +++++ 7 files changed, 97 insertions(+), 59 deletions(-) create mode 100644 tests/test_data/pyproject_pixi.toml create mode 100644 tests/test_data/pyproject_pixi_updated.toml diff --git a/run_spec0_update.py b/run_spec0_update.py index 00c629a..c03981e 100644 --- a/run_spec0_update.py +++ b/run_spec0_update.py @@ -35,9 +35,7 @@ ) project_data = read_toml(toml_path) - print(project_data) schedule_data = read_schedule(schedule_path) - print(schedule_data) update_pyproject_toml(project_data, schedule_data) write_toml(toml_path, project_data) diff --git a/spec0_action/__init__.py b/spec0_action/__init__.py index 725c6cc..85307fb 100644 --- a/spec0_action/__init__.py +++ b/spec0_action/__init__.py @@ -112,7 +112,6 @@ def update_pyproject_toml( pyproject_data["project"]["requires-python"] = repr_spec_set( parse_version_spec(new_version["packages"]["python"]) ) - update_pyproject_dependencies( pyproject_data["project"]["dependencies"], new_version ) diff --git a/tests/test_data/pyproject.toml b/tests/test_data/pyproject.toml index b06e4c5..f289a6f 100644 --- a/tests/test_data/pyproject.toml +++ b/tests/test_data/pyproject.toml @@ -1,30 +1,16 @@ -[project] -authors = [{ name = "Scientific Python Developers"}] -name = "tests" -description = "This is just a dummy package for testing the spec 0 update github action and should not be used" -requires-python = ">=3.10" -version = "0.1.0" -dependencies = ["ipython>=8.7.0,<4", "numpy[foo,bar]>=1.10.0,<2"] - [build-system] -build-backend = "hatchling.build" -requires = ["hatchling"] - -[tool.pixi.workspace] -channels = ["conda-forge"] -platforms = ["linux-64"] - -[tool.pixi.pypi-dependencies] -tests = { path = ".", editable = true } -scikit-learn = ">=1.2.0" +requires = [ + "setuptools>=62.1", + "setuptools_scm[toml]>=8.0.0", + "wheel", +] +build-backend = "setuptools.build_meta" -[tool.pixi.tasks] - -[tool.pixi.feature.foo.dependencies] -xarray = "*" - -[tool.pixi.environments] -bar = ["foo"] - -[tool.pixi.dependencies] -numpy = ">=1.10.0,<2" +[project] +name = "setuptools_test" +requires-python = ">=3.11" +dependencies = [ + 'numpy>=1.20.0,<2', + 'pandas>=1.0.0,<3', + 'xarray>=2021.1.0', +] diff --git a/tests/test_data/pyproject_pixi.toml b/tests/test_data/pyproject_pixi.toml new file mode 100644 index 0000000..b06e4c5 --- /dev/null +++ b/tests/test_data/pyproject_pixi.toml @@ -0,0 +1,30 @@ +[project] +authors = [{ name = "Scientific Python Developers"}] +name = "tests" +description = "This is just a dummy package for testing the spec 0 update github action and should not be used" +requires-python = ">=3.10" +version = "0.1.0" +dependencies = ["ipython>=8.7.0,<4", "numpy[foo,bar]>=1.10.0,<2"] + +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[tool.pixi.workspace] +channels = ["conda-forge"] +platforms = ["linux-64"] + +[tool.pixi.pypi-dependencies] +tests = { path = ".", editable = true } +scikit-learn = ">=1.2.0" + +[tool.pixi.tasks] + +[tool.pixi.feature.foo.dependencies] +xarray = "*" + +[tool.pixi.environments] +bar = ["foo"] + +[tool.pixi.dependencies] +numpy = ">=1.10.0,<2" diff --git a/tests/test_data/pyproject_pixi_updated.toml b/tests/test_data/pyproject_pixi_updated.toml new file mode 100644 index 0000000..a780ef7 --- /dev/null +++ b/tests/test_data/pyproject_pixi_updated.toml @@ -0,0 +1,30 @@ +[project] +authors = [{ name = "Scientific Python Developers"}] +name = "tests" +description = "This is just a dummy package for testing the spec 0 update github action and should not be used" +requires-python = ">=3.11" +version = "0.1.0" +dependencies = ["ipython>=8.8.0,<4", "numpy[foo,bar]>=1.25.0,<2"] + +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[tool.pixi.workspace] +channels = ["conda-forge"] +platforms = ["linux-64"] + +[tool.pixi.pypi-dependencies] +tests = { path = ".", editable = true } +scikit-learn = ">=1.3.0" + +[tool.pixi.tasks] + +[tool.pixi.feature.foo.dependencies] +xarray = ">=2023.1.0" + +[tool.pixi.environments] +bar = ["foo"] + +[tool.pixi.dependencies] +numpy = ">=1.25.0,<2" diff --git a/tests/test_data/pyproject_updated.toml b/tests/test_data/pyproject_updated.toml index a780ef7..8698dbb 100644 --- a/tests/test_data/pyproject_updated.toml +++ b/tests/test_data/pyproject_updated.toml @@ -1,30 +1,16 @@ -[project] -authors = [{ name = "Scientific Python Developers"}] -name = "tests" -description = "This is just a dummy package for testing the spec 0 update github action and should not be used" -requires-python = ">=3.11" -version = "0.1.0" -dependencies = ["ipython>=8.8.0,<4", "numpy[foo,bar]>=1.25.0,<2"] - [build-system] -build-backend = "hatchling.build" -requires = ["hatchling"] - -[tool.pixi.workspace] -channels = ["conda-forge"] -platforms = ["linux-64"] - -[tool.pixi.pypi-dependencies] -tests = { path = ".", editable = true } -scikit-learn = ">=1.3.0" +requires = [ + "setuptools>=62.1", + "setuptools_scm[toml]>=8.0.0", + "wheel", +] +build-backend = "setuptools.build_meta" -[tool.pixi.tasks] - -[tool.pixi.feature.foo.dependencies] -xarray = ">=2023.1.0" - -[tool.pixi.environments] -bar = ["foo"] - -[tool.pixi.dependencies] -numpy = ">=1.25.0,<2" +[project] +name = "setuptools_test" +requires-python = ">=3.11" +dependencies = [ + 'numpy>=1.25.0,<2', + 'pandas>=1.0.0,<3', + 'xarray>=2023.1.0', +] diff --git a/tests/test_update_pyproject_toml.py b/tests/test_update_pyproject_toml.py index 40397fb..494f7a8 100644 --- a/tests/test_update_pyproject_toml.py +++ b/tests/test_update_pyproject_toml.py @@ -9,3 +9,12 @@ def test_update_pyproject_toml(): update_pyproject_toml(pyproject_data, test_schedule) assert pyproject_data == expected + + +def test_update_pyproject_toml_with_pixi(): + expected = read_toml("tests/test_data/pyproject_pixi_updated.toml") + pyproject_data = read_toml("tests/test_data/pyproject_pixi.toml") + test_schedule = read_schedule("tests/test_data/test_schedule.json") + update_pyproject_toml(pyproject_data, test_schedule) + + assert pyproject_data == expected From 31c3862f99b827e7fee4a801f701bfee0db3dc3c Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 3 Dec 2025 11:04:52 -0800 Subject: [PATCH 03/10] Taken action from https://github.com/savente93/spec0-schedule --- .github/workflows/release_schedule.yaml | 72 +++++++++++++++ action.yaml | 2 +- spec0_versions.py | 111 +++++++++++++----------- 3 files changed, 133 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/release_schedule.yaml diff --git a/.github/workflows/release_schedule.yaml b/.github/workflows/release_schedule.yaml new file mode 100644 index 0000000..627926a --- /dev/null +++ b/.github/workflows/release_schedule.yaml @@ -0,0 +1,72 @@ +name: Generate release schedule artifacts +on: + schedule: + # At 00:00 on day-of-month 2 in every 3rd month. (i.e. every quarter) + # 2nd is chosen to avoid fencepost errors + - cron: "0 0 2 */3 *" + push: + branches: + - "main" + pull_request: + # On demand + workflow_dispatch: + +jobs: + create-artifacts: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + # We're going to make a tag that we can we release so we'll need the full history for that + fetch-depth: 0 + + - uses: prefix-dev/setup-pixi@v0.9.2 + with: + pixi-version: "v0.49.0" + + - name: Generate artifacts + run: | + pixi run generate-schedule --locked + + - name: Test json artifact + run: | + cat schedule.json | jq + + - name: setup git + run: | + # git will complain if we don't do this first + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: determine tag name + id: tag_name + run: | + echo "TAG_NAME=$(date '+%Y-Q%q')" >> "$GITHUB_OUTPUT" + - name: Commit artifacts and create tag + env: + GH_TOKEN: ${{ secrets.PAT }} + run: | + git add schedule.md chart.md schedule.json + git commit -m "generate schedule for ${{ steps.tag_name.outputs.TAG_NAME }} release" || echo "No changes to commit" + git tag ${{ steps.tag_name.outputs.TAG_NAME }} + git push origin main + git push origin tag ${{ steps.tag_name.outputs.TAG_NAME }} + + - name: Publish github release + uses: softprops/action-gh-release@v2 + env: + GH_TOKEN: ${{ secrets.PAT }} + with: + generate_release_notes: true + tag_name: ${{ steps.tag_name.outputs.TAG_NAME }} + make_latest: true + files: | + schedule.md + chart.md + schedule.json + +permissions: + contents: write diff --git a/action.yaml b/action.yaml index 120ad0d..283fd85 100644 --- a/action.yaml +++ b/action.yaml @@ -58,7 +58,7 @@ runs: echo "Regenerating schedule.json..." pixi run generate-schedule --locked if diff -q schedule.json "${{ github.workspace }}/$SCHEDULE_FILE" >/dev/null; then - echo "Source and destination have identical contents – nothing to move." + echo "Source and destination have identical contents - nothing to move." else mv schedule.json "${{ github.workspace }}/$SCHEDULE_FILE" fi diff --git a/spec0_versions.py b/spec0_versions.py index 8786f45..5789dd3 100644 --- a/spec0_versions.py +++ b/spec0_versions.py @@ -1,77 +1,93 @@ import requests -import json import collections from datetime import datetime, timedelta import pandas as pd -from packaging.version import Version +from packaging.version import Version, InvalidVersion -PY_RELEASES = { +py_releases = { "3.8": "Oct 14, 2019", "3.9": "Oct 5, 2020", "3.10": "Oct 4, 2021", "3.11": "Oct 24, 2022", "3.12": "Oct 2, 2023", "3.13": "Oct 7, 2024", + "3.14": "Oct 7, 2025", } -CORE_PACKAGES = [ - "ipython", - "matplotlib", - "networkx", +core_packages = [ + # Path(x).stem for x in glob("../core-projects/*.md") if "_index" not in x "numpy", + "scipy", + "matplotlib", "pandas", "scikit-image", + "networkx", "scikit-learn", - "scipy", "xarray", + "ipython", "zarr", ] -PLUS_36_MONTHS = timedelta(days=int(365 * 3)) -PLUS_24_MONTHS = timedelta(days=int(365 * 2)) +plus36 = timedelta(days=int(365 * 3)) +plus24 = timedelta(days=int(365 * 2)) # Release data -# We put the cutoff at 3 quarters ago - we do not use "just" -9 months -# to avoid the content of the quarter to change depending on when we -# generate this file during the current quarter. -CURRENT_DATE = pd.Timestamp.now() -CURRENT_QUARTER_START = pd.Timestamp( - CURRENT_DATE.year, (CURRENT_DATE.quarter - 1) * 3 + 1, 1 + +# put cutoff 3 quarters ago – we do not use "just" -9 month, +# to avoid the content of the quarter to change depending on when we generate this +# file during the current quarter. + +current_date = pd.Timestamp.now() +current_quarter_start = pd.Timestamp( + current_date.year, (current_date.quarter - 1) * 3 + 1, 1 ) -CUTOFF = CURRENT_QUARTER_START - pd.DateOffset(months=9) +cutoff = current_quarter_start - pd.DateOffset(months=9) -def get_release_dates(package, support_time=PLUS_24_MONTHS): +def get_release_dates(package, support_time=plus24): releases = {} + print(f"Querying pypi.org for {package} versions...", end="", flush=True) response = requests.get( f"https://pypi.org/simple/{package}", headers={"Accept": "application/vnd.pypi.simple.v1+json"}, ).json() print("OK") + file_date = collections.defaultdict(list) for f in response["files"]: - if f["filename"].endswith(".tar.gz") or f["filename"].endswith(".zip"): - continue ver = f["filename"].split("-")[1] try: version = Version(ver) - except Exception: + except InvalidVersion as e: + print(f"Error: '{ver}' is an invalid version for '{package}'. Reason: {e}") continue + if version.is_prerelease or version.micro != 0: continue - release_date = pd.Timestamp(f["upload-time"]).tz_localize(None) + + release_date = None + for format in ["%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"]: + try: + release_date = datetime.strptime(f["upload-time"], format) + except ValueError as e: + print(f"Error parsing invalid date: {e}") + if not release_date: continue + file_date[version].append(release_date) + release_date = {v: min(file_date[v]) for v in file_date} + for ver, release_date in sorted(release_date.items()): drop_date = release_date + support_time - if drop_date >= CUTOFF: + if drop_date >= cutoff: releases[ver] = { "release_date": release_date, "drop_date": drop_date, } + return releases @@ -79,23 +95,27 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS): "python": { version: { "release_date": datetime.strptime(release_date, "%b %d, %Y"), - "drop_date": datetime.strptime(release_date, "%b %d, %Y") + PLUS_36_MONTHS, + "drop_date": datetime.strptime(release_date, "%b %d, %Y") + plus36, } - for version, release_date in PY_RELEASES.items() + for version, release_date in py_releases.items() } } -package_releases |= {package: get_release_dates(package) for package in CORE_PACKAGES} -# Filter all items whose drop_date are in the past + +package_releases |= {package: get_release_dates(package) for package in core_packages} + +# filter all items whose drop_date are in the past package_releases = { package: { version: dates for version, dates in releases.items() - if dates["drop_date"] > CUTOFF + if dates["drop_date"] > cutoff } for package, releases in package_releases.items() } + # Save Gantt chart + print("Saving Mermaid chart to chart.md") with open("chart.md", "w") as fh: fh.write( @@ -104,6 +124,7 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS): axisFormat %m / %Y title Support Window""" ) + for name, releases in package_releases.items(): fh.write(f"\n\nsection {name}") for version, dates in releases.items(): @@ -113,6 +134,7 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS): fh.write("\n") # Print drop schedule + data = [] for k, versions in package_releases.items(): for v, dates in versions.items(): @@ -124,30 +146,15 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS): pd.to_datetime(dates["drop_date"]), ) ) + df = pd.DataFrame(data, columns=["package", "version", "release", "drop"]) + df["quarter"] = df["drop"].dt.to_period("Q") -df["new_min_version"] = ( - df[["package", "version", "quarter"]].groupby("package").shift(-1)["version"] -) -dq = df.set_index(["quarter", "package"]).sort_index().dropna() -new_min_versions = ( - dq.groupby(["quarter", "package"]).agg({"new_min_version": "max"}).reset_index() -) -# We want to build a dict with the structure [{start_date: timestamp, packages: {package: lower_bound}}] -new_min_versions_list = [] -for q, packages in new_min_versions.groupby("quarter"): - package_lower_bounds = { - p: str(v) for p, v in packages.drop("quarter", axis=1).itertuples(index=False) - } - # jq is really insistent the Z should be there - quarter_start_time_str = str(q.start_time.isoformat()) + "Z" - new_min_versions_list.append( - {"start_date": quarter_start_time_str, "packages": package_lower_bounds} - ) -print("Saving drop schedule to schedule.json") -with open("schedule.json", "w") as f: - f.write(json.dumps(new_min_versions_list, sort_keys=True)) +dq = df.set_index(["quarter", "package"]).sort_index() + + +print("Saving drop schedule to schedule.md") def pad_table(table): @@ -165,6 +172,7 @@ def pad_table(table): line += f"| {str.ljust(entry, width)} " line += "|" padded_table.append(line) + return padded_table @@ -184,6 +192,7 @@ def make_table(sub): else f"{rel_min.strftime('%b %Y')} and {rel_max.strftime('%b %Y')}" ) table.append(f"|{package:<15}|{version_range:<19}|released {rel_range}|") + return pad_table(table) @@ -195,13 +204,13 @@ def make_quarter(quarter, dq): return "\n".join(table) -print("Saving drop schedule to schedule.md") with open("schedule.md", "w") as fh: - # We collect packages 6 month in the past, and drop the first quarter + # we collect package 6 month in the past, and drop the first quarter # as we might have filtered some of the packages out depending on # when we ran the script. tb = [] for quarter in list(sorted(set(dq.index.get_level_values(0))))[1:]: tb.append(make_quarter(quarter, dq)) + fh.write("\n\n".join(tb)) fh.write("\n") From a3fb6f366ba7701421e3a4884429e3d34a833605 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 3 Dec 2025 11:09:12 -0800 Subject: [PATCH 04/10] Undo update of spec0_action but with upstream changes --- .github/workflows/release_schedule.yaml | 7 +- spec0_versions.py | 98 ++++++++++++------------- 2 files changed, 51 insertions(+), 54 deletions(-) diff --git a/.github/workflows/release_schedule.yaml b/.github/workflows/release_schedule.yaml index 627926a..dabdb70 100644 --- a/.github/workflows/release_schedule.yaml +++ b/.github/workflows/release_schedule.yaml @@ -7,7 +7,6 @@ on: push: branches: - "main" - pull_request: # On demand workflow_dispatch: @@ -47,9 +46,9 @@ jobs: echo "TAG_NAME=$(date '+%Y-Q%q')" >> "$GITHUB_OUTPUT" - name: Commit artifacts and create tag env: - GH_TOKEN: ${{ secrets.PAT }} + GH_TOKEN: ${{ secrets.RELEASE_PAT }} run: | - git add schedule.md chart.md schedule.json + git add -f schedule.md chart.md schedule.json git commit -m "generate schedule for ${{ steps.tag_name.outputs.TAG_NAME }} release" || echo "No changes to commit" git tag ${{ steps.tag_name.outputs.TAG_NAME }} git push origin main @@ -58,7 +57,7 @@ jobs: - name: Publish github release uses: softprops/action-gh-release@v2 env: - GH_TOKEN: ${{ secrets.PAT }} + GH_TOKEN: ${{ secrets.RELEASE_PAT }} with: generate_release_notes: true tag_name: ${{ steps.tag_name.outputs.TAG_NAME }} diff --git a/spec0_versions.py b/spec0_versions.py index 5789dd3..4b7a0f8 100644 --- a/spec0_versions.py +++ b/spec0_versions.py @@ -1,4 +1,5 @@ import requests +import json import collections from datetime import datetime, timedelta @@ -6,7 +7,7 @@ from packaging.version import Version, InvalidVersion -py_releases = { +PY_RELEASES = { "3.8": "Oct 14, 2019", "3.9": "Oct 5, 2020", "3.10": "Oct 4, 2021", @@ -15,79 +16,69 @@ "3.13": "Oct 7, 2024", "3.14": "Oct 7, 2025", } -core_packages = [ - # Path(x).stem for x in glob("../core-projects/*.md") if "_index" not in x - "numpy", - "scipy", +CORE_PACKAGES = [ + "ipython", "matplotlib", + "networkx", + "numpy", "pandas", "scikit-image", - "networkx", "scikit-learn", + "scipy", "xarray", - "ipython", "zarr", ] -plus36 = timedelta(days=int(365 * 3)) -plus24 = timedelta(days=int(365 * 2)) +PLUS_36_MONTHS = timedelta(days=int(365 * 3)) +PLUS_24_MONTHS = timedelta(days=int(365 * 2)) # Release data - -# put cutoff 3 quarters ago – we do not use "just" -9 month, -# to avoid the content of the quarter to change depending on when we generate this -# file during the current quarter. - -current_date = pd.Timestamp.now() -current_quarter_start = pd.Timestamp( - current_date.year, (current_date.quarter - 1) * 3 + 1, 1 +# We put the cutoff at 3 quarters ago - we do not use "just" -9 months +# to avoid the content of the quarter to change depending on when we +# generate this file during the current quarter. +CURRENT_DATE = pd.Timestamp.now() +CURRENT_QUARTER_START = pd.Timestamp( + CURRENT_DATE.year, (CURRENT_DATE.quarter - 1) * 3 + 1, 1 ) -cutoff = current_quarter_start - pd.DateOffset(months=9) +CUTOFF = CURRENT_QUARTER_START - pd.DateOffset(months=9) -def get_release_dates(package, support_time=plus24): +def get_release_dates(package, support_time=PLUS_24_MONTHS): releases = {} - print(f"Querying pypi.org for {package} versions...", end="", flush=True) response = requests.get( f"https://pypi.org/simple/{package}", headers={"Accept": "application/vnd.pypi.simple.v1+json"}, ).json() print("OK") - file_date = collections.defaultdict(list) for f in response["files"]: + if f["filename"].endswith(".tar.gz") or f["filename"].endswith(".zip"): + continue ver = f["filename"].split("-")[1] try: version = Version(ver) except InvalidVersion as e: print(f"Error: '{ver}' is an invalid version for '{package}'. Reason: {e}") continue - if version.is_prerelease or version.micro != 0: continue - release_date = None for format in ["%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"]: try: release_date = datetime.strptime(f["upload-time"], format) except ValueError as e: print(f"Error parsing invalid date: {e}") - if not release_date: continue - file_date[version].append(release_date) - release_date = {v: min(file_date[v]) for v in file_date} - for ver, release_date in sorted(release_date.items()): drop_date = release_date + support_time - if drop_date >= cutoff: + if drop_date >= CUTOFF: releases[ver] = { "release_date": release_date, "drop_date": drop_date, } - return releases @@ -95,27 +86,23 @@ def get_release_dates(package, support_time=plus24): "python": { version: { "release_date": datetime.strptime(release_date, "%b %d, %Y"), - "drop_date": datetime.strptime(release_date, "%b %d, %Y") + plus36, + "drop_date": datetime.strptime(release_date, "%b %d, %Y") + PLUS_36_MONTHS, } - for version, release_date in py_releases.items() + for version, release_date in PY_RELEASES.items() } } - -package_releases |= {package: get_release_dates(package) for package in core_packages} - -# filter all items whose drop_date are in the past +package_releases |= {package: get_release_dates(package) for package in CORE_PACKAGES} +# Filter all items whose drop_date are in the past package_releases = { package: { version: dates for version, dates in releases.items() - if dates["drop_date"] > cutoff + if dates["drop_date"] > CUTOFF } for package, releases in package_releases.items() } - # Save Gantt chart - print("Saving Mermaid chart to chart.md") with open("chart.md", "w") as fh: fh.write( @@ -124,7 +111,6 @@ def get_release_dates(package, support_time=plus24): axisFormat %m / %Y title Support Window""" ) - for name, releases in package_releases.items(): fh.write(f"\n\nsection {name}") for version, dates in releases.items(): @@ -134,7 +120,6 @@ def get_release_dates(package, support_time=plus24): fh.write("\n") # Print drop schedule - data = [] for k, versions in package_releases.items(): for v, dates in versions.items(): @@ -146,15 +131,30 @@ def get_release_dates(package, support_time=plus24): pd.to_datetime(dates["drop_date"]), ) ) - df = pd.DataFrame(data, columns=["package", "version", "release", "drop"]) - df["quarter"] = df["drop"].dt.to_period("Q") +df["new_min_version"] = ( + df[["package", "version", "quarter"]].groupby("package").shift(-1)["version"] +) +dq = df.set_index(["quarter", "package"]).sort_index().dropna() +new_min_versions = ( + dq.groupby(["quarter", "package"]).agg({"new_min_version": "max"}).reset_index() +) -dq = df.set_index(["quarter", "package"]).sort_index() - - -print("Saving drop schedule to schedule.md") +# We want to build a dict with the structure [{start_date: timestamp, packages: {package: lower_bound}}] +new_min_versions_list = [] +for q, packages in new_min_versions.groupby("quarter"): + package_lower_bounds = { + p: str(v) for p, v in packages.drop("quarter", axis=1).itertuples(index=False) + } + # jq is really insistent the Z should be there + quarter_start_time_str = str(q.start_time.isoformat()) + "Z" + new_min_versions_list.append( + {"start_date": quarter_start_time_str, "packages": package_lower_bounds} + ) +print("Saving drop schedule to schedule.json") +with open("schedule.json", "w") as f: + f.write(json.dumps(new_min_versions_list, sort_keys=True)) def pad_table(table): @@ -172,7 +172,6 @@ def pad_table(table): line += f"| {str.ljust(entry, width)} " line += "|" padded_table.append(line) - return padded_table @@ -192,7 +191,6 @@ def make_table(sub): else f"{rel_min.strftime('%b %Y')} and {rel_max.strftime('%b %Y')}" ) table.append(f"|{package:<15}|{version_range:<19}|released {rel_range}|") - return pad_table(table) @@ -204,13 +202,13 @@ def make_quarter(quarter, dq): return "\n".join(table) +print("Saving drop schedule to schedule.md") with open("schedule.md", "w") as fh: - # we collect package 6 month in the past, and drop the first quarter + # We collect packages 6 month in the past, and drop the first quarter # as we might have filtered some of the packages out depending on # when we ran the script. tb = [] for quarter in list(sorted(set(dq.index.get_level_values(0))))[1:]: tb.append(make_quarter(quarter, dq)) - fh.write("\n\n".join(tb)) fh.write("\n") From 73026bc6430cfc971792eed45261a75bc1282836 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 3 Dec 2025 11:14:06 -0800 Subject: [PATCH 05/10] Update readme --- readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 3c1532f..60fe253 100644 --- a/readme.md +++ b/readme.md @@ -41,7 +41,7 @@ It should update any of the packages listed in the `dependency`, or `tool.pixi.* For examples of before and after you can see [./tests/test_data/pyproject.toml](./tests/test_data/pyproject.toml) and [./tests/test_data/pyproject_updated.toml](./tests/test_data/pyproject_updated.toml) respectively. Other tools are not yet supported, but we are open to feature requests. -The newest lower bounds will be downloaded from [https://github.com/savente93/SPEC0-schedule](https://github.com/savente93/SPEC0-schedule) but you should not have to worry about this. +The newest lower bounds will be downloaded from [https://github.com/scientific-python/spec0-action](https://github.com/scientific-python/spec0-action) but you should not have to worry about this. ### Parameters From 9d8bba4c935e42aeaab16ed89295fc859064eaf6 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 3 Dec 2025 11:22:59 -0800 Subject: [PATCH 06/10] Prototype change --- action.yaml | 30 ++++++------------------------ 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/action.yaml b/action.yaml index 283fd85..c7b8b4f 100644 --- a/action.yaml +++ b/action.yaml @@ -34,52 +34,34 @@ runs: steps: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Git shell: bash run: | git config user.name "Scientific Python [bot]" git config user.email "scientific-python@users.noreply.github.com" - - uses: prefix-dev/setup-pixi@v0.9.3 name: Setup Pixi with: pixi-version: v0.49.0 manifest-path: ${{ github.action_path }}/pyproject.toml - - - name: Regenerate schedule file if necessary - shell: bash - env: - SCHEDULE_FILE: ${{ inputs.schedule_path }} - GH_TOKEN: ${{ inputs.token }} - run: | - set -e - if [ ! -f "${{ github.workspace }}/$SCHEDULE_FILE" ]; then - echo "Regenerating schedule.json..." - pixi run generate-schedule --locked - if diff -q schedule.json "${{ github.workspace }}/$SCHEDULE_FILE" >/dev/null; then - echo "Source and destination have identical contents - nothing to move." - else - mv schedule.json "${{ github.workspace }}/$SCHEDULE_FILE" - fi - else - echo "Schedule file already exists at $SCHEDULE_FILE" - fi - + - name: Fetch Schedule from release + uses: robinraju/release-downloader@v1.3 + with: + repository: "savente93/SPEC0-schedule" + latest: true + fileName: "schedule.json" - name: Run update script shell: bash run: | set -e echo "Updating ${{inputs.project_file_name}} using schedule ${{inputs.schedule_path}}" pixi run --manifest-path ${{ github.action_path }}/pyproject.toml update-dependencies "${{ github.workspace }}/${{ inputs.project_file_name }}" "${{ github.workspace }}/${{ inputs.schedule_path }}" - - name: Show changes (dry-run) if: ${{ inputs.create_pr != 'true' }} shell: bash run: | echo "Dry run: showing changes that would be committed" git --no-pager diff ${{ inputs.project_file_name }} - - name: Create Pull Request if: ${{ inputs.create_pr == 'true' }} uses: peter-evans/create-pull-request@v7 From a46765383905e249f4c283ab26c615b1416c048f Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 3 Dec 2025 11:41:36 -0800 Subject: [PATCH 07/10] Show changes --- action.yaml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/action.yaml b/action.yaml index c7b8b4f..27b09f1 100644 --- a/action.yaml +++ b/action.yaml @@ -56,11 +56,10 @@ runs: set -e echo "Updating ${{inputs.project_file_name}} using schedule ${{inputs.schedule_path}}" pixi run --manifest-path ${{ github.action_path }}/pyproject.toml update-dependencies "${{ github.workspace }}/${{ inputs.project_file_name }}" "${{ github.workspace }}/${{ inputs.schedule_path }}" - - name: Show changes (dry-run) - if: ${{ inputs.create_pr != 'true' }} + - name: Show changes shell: bash run: | - echo "Dry run: showing changes that would be committed" + echo "Showing changes that would be committed" git --no-pager diff ${{ inputs.project_file_name }} - name: Create Pull Request if: ${{ inputs.create_pr == 'true' }} From ba570331ab63e0e41567c385bdbfdacd134a0ea5 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 3 Dec 2025 11:45:34 -0800 Subject: [PATCH 08/10] dont make pr if empty? --- action.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/action.yaml b/action.yaml index 27b09f1..61b5c14 100644 --- a/action.yaml +++ b/action.yaml @@ -56,11 +56,15 @@ runs: set -e echo "Updating ${{inputs.project_file_name}} using schedule ${{inputs.schedule_path}}" pixi run --manifest-path ${{ github.action_path }}/pyproject.toml update-dependencies "${{ github.workspace }}/${{ inputs.project_file_name }}" "${{ github.workspace }}/${{ inputs.schedule_path }}" - - name: Show changes + - name: Show changes and skip if none shell: bash 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 + echo "No changes to commit" + exit 0 + fi - name: Create Pull Request if: ${{ inputs.create_pr == 'true' }} uses: peter-evans/create-pull-request@v7 From 1ae2327299b349f6d63e50dc29069977ab4aa5bd Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 3 Dec 2025 11:52:02 -0800 Subject: [PATCH 09/10] no idea --- action.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/action.yaml b/action.yaml index 61b5c14..8f25614 100644 --- a/action.yaml +++ b/action.yaml @@ -56,17 +56,18 @@ runs: set -e echo "Updating ${{inputs.project_file_name}} using schedule ${{inputs.schedule_path}}" pixi run --manifest-path ${{ github.action_path }}/pyproject.toml update-dependencies "${{ github.workspace }}/${{ inputs.project_file_name }}" "${{ github.workspace }}/${{ inputs.schedule_path }}" - - name: Show changes and skip if none + - name: Changes shell: bash 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 - echo "No changes to commit" - exit 0 + echo "changes_detected=false" >> $GITHUB_OUTPUT + else + echo "changes_detected=true" >> $GITHUB_OUTPUT fi - name: Create Pull Request - if: ${{ inputs.create_pr == 'true' }} + if: ${{ inputs.create_pr == 'true' }} && steps.Changes.outputs.changes_detected == 'true' uses: peter-evans/create-pull-request@v7 with: token: ${{ inputs.token }} From 2e0c0654795b7ba4357e5eb4bd7ee3ad60517273 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Wed, 3 Dec 2025 12:02:38 -0800 Subject: [PATCH 10/10] Maybe? --- action.yaml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/action.yaml b/action.yaml index 8f25614..3cfa502 100644 --- a/action.yaml +++ b/action.yaml @@ -1,6 +1,8 @@ +# action.yml name: "Update SPEC 0 dependencies" description: "Update the lower bounds of Python dependencies covered by the Scientific Python SPEC 0 support schedule" author: Scientific Python Developers + inputs: target_branch: description: "Target branch for the pull request" @@ -54,29 +56,31 @@ runs: shell: bash run: | set -e - echo "Updating ${{inputs.project_file_name}} using schedule ${{inputs.schedule_path}}" - pixi run --manifest-path ${{ github.action_path }}/pyproject.toml update-dependencies "${{ github.workspace }}/${{ inputs.project_file_name }}" "${{ github.workspace }}/${{ inputs.schedule_path }}" + echo "Updating ${{ inputs.project_file_name }} using schedule ${{ inputs.schedule_path }}" + pixi run --manifest-path ${{ github.action_path }}/pyproject.toml update-dependencies "${{ github.workspace }}/${{ inputs.project_file_name }}" "${{ github.workspace }}/${{ inputs.schedule_path }}" - name: Changes + id: changes shell: bash 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 - echo "changes_detected=false" >> $GITHUB_OUTPUT + echo "changes_detected=false" >> "$GITHUB_OUTPUT" else - echo "changes_detected=true" >> $GITHUB_OUTPUT + echo "changes_detected=true" >> "$GITHUB_OUTPUT" fi - name: Create Pull Request - if: ${{ inputs.create_pr == 'true' }} && steps.Changes.outputs.changes_detected == 'true' + if: ${{ fromJSON(inputs.create_pr) && fromJSON(steps.changes.outputs.changes_detected) }} uses: peter-evans/create-pull-request@v7 with: token: ${{ inputs.token }} commit-message: ${{ inputs.commit_msg }} - path: ${{ inputs.project_file_name }} title: ${{ inputs.pr_title }} body: "This PR was created automatically" base: ${{ inputs.target_branch }} branch: update-spec0-dependencies-${{ github.run_id }} + add-paths: | + ${{ inputs.project_file_name }} branding: icon: "check-square"