From c0a2d09e55fa3110d3edd2ddfb6053550251a66b Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Sat, 5 Sep 2026 20:19:46 -0700 Subject: [PATCH 1/6] chore: add .DS_Store --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 82c7512..611d20d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ schedule.json __pycache__ *.pyc *.lock +.DS_Store From b2807101bae28d140e95ee655f98a7222f9bf07d Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Sat, 5 Sep 2026 17:37:46 -0700 Subject: [PATCH 2/6] feat: support package exclusions in the updater --- spec0_action/__init__.py | 8 ++ tests/test_update_pyproject_toml.py | 139 +++++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 1 deletion(-) diff --git a/spec0_action/__init__.py b/spec0_action/__init__.py index 6d8737a..fc73dae 100644 --- a/spec0_action/__init__.py +++ b/spec0_action/__init__.py @@ -182,7 +182,10 @@ def update_pyproject_toml( pyproject_data: dict, schedule_data: Sequence[SupportSchedule], update_all: float | None = None, + *, + excluded_packages: Sequence[str] = (), ): + excluded = {canonicalize_name(pkg, validate=True) for pkg in excluded_packages} now = datetime.datetime.now(datetime.UTC) applicable = sorted( filter( @@ -200,6 +203,9 @@ def update_pyproject_toml( raise RuntimeError( "Could not find schedule that applies to current time, perhaps your schedule is outdated." ) + new_version = { + pkg: version for pkg, version in new_version.items() if pkg not in excluded + } project_data = pyproject_data.get("project", {}) if not isinstance(project_data, dict): project_data = {} @@ -212,6 +218,8 @@ def update_pyproject_toml( _update_requires_python(project_data, new_version["python"]) def resolve_lower_bound(package_key: str) -> Version | None: + if package_key in excluded: + return None if package_key in new_version: return new_version[package_key] if update_all is not None: diff --git a/tests/test_update_pyproject_toml.py b/tests/test_update_pyproject_toml.py index fecc1e8..eb79911 100644 --- a/tests/test_update_pyproject_toml.py +++ b/tests/test_update_pyproject_toml.py @@ -1,8 +1,10 @@ import datetime +from copy import deepcopy from unittest.mock import patch import pytest from packaging.version import Version +from tomlkit import dumps from spec0_action.parsing import read_schedule, read_toml from spec0_action import update_pyproject_toml @@ -162,7 +164,7 @@ def test_self_reference_skipped_even_when_in_schedule(patch_datetime_now, schedu (None, ">=3.12"), # incompatible: preserved byte-exact, not rewritten in normalized form (">= 3.9, < 3.12", ">= 3.9, < 3.12"), - # unparseable (poetry-style): left alone + # unparsable (poetry-style): left alone ("^3.10", "^3.10"), ], ) @@ -343,3 +345,138 @@ def fake_get(url, **kwargs): assert requested_urls == ["https://pypi.org/simple/demo-pkg"] assert pyproject["project"]["dependencies"] == ["Demo_Pkg>=2.0.0"] assert pyproject["dependency-groups"]["dev"] == ["demo-pkg>=2.0.0"] + + +@pytest.mark.parametrize("update_all", [None, 2.0]) +def test_excluded_pep_dependencies(patch_datetime_now, schedule, update_all): + deps = [ + "NumPy[foo,bar] >= 1.10.0 ; python_version < '4'", + "scikit_LEARN >= 1.0", + "requests[socks] >= 2.0 ; sys_platform == 'win32'", + "pandas>=1.0", + ] + pyproject = _minimal_pyproject(*deps) + pyproject["project"]["optional-dependencies"] = {"test": deps.copy()} + pyproject["dependency-groups"] = {"dev": deps.copy()} + + with _mock_pypi() as mock_pypi: + update_pyproject_toml( + pyproject, + schedule, + update_all, + excluded_packages=["numpy", "NUMPY", "Scikit.Learn", "requests", "absent"], + ) + + mock_pypi.assert_not_called() + expected = deps[:-1] + ["pandas>=2.2.0"] + assert pyproject["project"]["dependencies"] == expected + assert pyproject["project"]["optional-dependencies"]["test"] == expected + assert pyproject["dependency-groups"]["dev"] == expected + + +@pytest.mark.parametrize( + "location", + [ + (), + ("feature", "test"), + ("target", "linux-64"), + ("feature", "test", "target", "linux-64"), + ], +) +@pytest.mark.parametrize("table_name", ["dependencies", "pypi-dependencies"]) +def test_excluded_pixi_dependencies(patch_datetime_now, schedule, location, table_name): + deps = { + "NumPy": ">= 1.10.0", + "scikit_learn": {"version": ">= 1.0", "extras": ["test"]}, + "pandas": {"version": ">=1.0", "extras": ["test"]}, + } + expected = deepcopy(deps) + expected["pandas"]["version"] = ">=2.2.0" + pyproject = _minimal_pyproject() + table = pyproject.setdefault("tool", {}).setdefault("pixi", {}) + for key in location: + table = table.setdefault(key, {}) + table[table_name] = deps + + update_pyproject_toml( + pyproject, schedule, excluded_packages=["numpy", "SCIKIT.LEARN"] + ) + + assert table[table_name] == expected + + +@pytest.mark.parametrize("current", [None, ">= 3.9, < 4"]) +def test_excluded_python(patch_datetime_now, schedule, current): + pyproject = _minimal_pyproject("numpy>=1.10.0") + if current is None: + del pyproject["project"]["requires-python"] + else: + pyproject["project"]["requires-python"] = current + pyproject["tool"] = { + "pixi": { + "dependencies": {"Python": ">= 3.9"}, + "feature": { + "test": { + "target": { + "linux-64": { + "pypi-dependencies": { + "python": {"version": ">= 3.9", "extras": ["test"]} + } + } + } + } + }, + } + } + expected_tool = deepcopy(pyproject["tool"]) + + update_pyproject_toml(pyproject, schedule, excluded_packages=["PYTHON"]) + + if current is None: + assert "requires-python" not in pyproject["project"] + else: + assert pyproject["project"]["requires-python"] == current + assert pyproject["tool"] == expected_tool + assert pyproject["project"]["dependencies"] == ["numpy>=2.0.0"] + + +@pytest.mark.parametrize("filename", ["pyproject", "pyproject_pixi"]) +@pytest.mark.parametrize("exclude_all", [False, True]) +def test_empty_and_all_exclusions(patch_datetime_now, schedule, filename, exclude_all): + pyproject = read_toml(f"tests/test_data/{filename}.toml") + expected = deepcopy(pyproject) + if not exclude_all: + update_pyproject_toml(expected, schedule) + excluded_packages = ( + [pkg for entry in schedule for pkg in entry["packages"]] if exclude_all else () + ) + + with _mock_pypi() as mock_pypi: + update_pyproject_toml( + pyproject, schedule, 2.0, excluded_packages=excluded_packages + ) + + mock_pypi.assert_not_called() + assert dumps(pyproject) == dumps(expected) + + +@pytest.mark.parametrize( + "invalid", ["numpy>=1", "numpy[extra]", "numpy*", "*", "-numpy", "numpy pandas"] +) +def test_invalid_exclusions_fail_before_mutation(patch_datetime_now, schedule, invalid): + pyproject = _minimal_pyproject("numpy>=1.10.0") + expected = deepcopy(pyproject) + + with _mock_pypi() as mock_pypi, pytest.raises(ValueError): + update_pyproject_toml( + pyproject, schedule, 2.0, excluded_packages=["pandas", invalid] + ) + + mock_pypi.assert_not_called() + assert pyproject == expected + + +def test_exclusions_do_not_hide_invalid_schedule(patch_datetime_now): + pyproject = _minimal_pyproject("numpy>=1.10.0") + with pytest.raises(RuntimeError, match="Could not find schedule"): + update_pyproject_toml(pyproject, [], excluded_packages=["numpy", "python"]) From 1386635445285ad7824924710fd07cd1ec717234 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Sat, 5 Sep 2026 20:41:44 -0700 Subject: [PATCH 3/6] feat: expose package exclusions through the CLI and action --- .github/workflows/test_action.yaml | 31 ++++++++++++++- action.yaml | 14 +++++-- readme.md | 27 +++++++++++++ run_spec0_update.py | 13 ++++++- tests/test_cli.py | 61 ++++++++++++++++++++++++++++++ tests/test_spec0_versions.py | 52 +++++++++++++++++++++++++ 6 files changed, 193 insertions(+), 5 deletions(-) create mode 100644 tests/test_cli.py create mode 100644 tests/test_spec0_versions.py diff --git a/.github/workflows/test_action.yaml b/.github/workflows/test_action.yaml index 7960c8b..9e05105 100644 --- a/.github/workflows/test_action.yaml +++ b/.github/workflows/test_action.yaml @@ -17,7 +17,10 @@ concurrency: jobs: generate_data: runs-on: ubuntu-latest - name: Run action on test file in repo + strategy: + matrix: + excluded_packages: ["", "numpy,\nscikit-learn"] + name: "Run action with exclusions: ${{ matrix.excluded_packages }}" steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -27,3 +30,29 @@ jobs: project_file_name: tests/test_data/pyproject.toml create_pr: false schedule_path: tests/test_data/test_schedule.json + excluded_packages: ${{ matrix.excluded_packages }} + - name: Check dependency updates + env: + EXCLUDED_PACKAGES: ${{ matrix.excluded_packages }} + run: | + pixi run python - <<'PY' + import os + import subprocess + import tomllib + from pathlib import Path + + path = "tests/test_data/pyproject.toml" + original = tomllib.loads(subprocess.check_output(["git", "show", f"HEAD:{path}"], text=True)) + updated = tomllib.loads(Path(path).read_text()) + before = original["project"]["dependencies"] + after = updated["project"]["dependencies"] + for package in ("numpy", "scikit-learn"): + old = [dep for dep in before if dep.startswith(package)] + new = [dep for dep in after if dep.startswith(package)] + assert old and new + if os.environ["EXCLUDED_PACKAGES"]: + assert new == old, (old, new) + else: + assert new != old, (old, new) + assert [dep for dep in after if dep.startswith("pandas")] != [dep for dep in before if dep.startswith("pandas")] + PY diff --git a/action.yaml b/action.yaml index 3a2aedb..7d79b4a 100644 --- a/action.yaml +++ b/action.yaml @@ -38,6 +38,10 @@ inputs: description: "If set, also update all non-SPEC0 dependencies to versions released within the last N years (e.g., 2)." required: false default: "" + excluded_packages: + description: "Comma- or whitespace-separated package names to leave unchanged, including with update_all. Use python to exclude Python requirements." + required: false + default: "" runs: using: "composite" steps: @@ -66,6 +70,7 @@ runs: PROJECT_FILE_NAME: ${{ inputs.project_file_name }} SCHEDULE_INPUT: ${{ inputs.schedule_path }} UPDATE_ALL: ${{ inputs.update_all }} + EXCLUDED_PACKAGES: ${{ inputs.excluded_packages }} run: | set -e if [ -n "$SCHEDULE_INPUT" ]; then @@ -74,11 +79,14 @@ runs: SCHEDULE_PATH="${GITHUB_WORKSPACE}/schedule.json" fi echo "Updating ${PROJECT_FILE_NAME} using schedule ${SCHEDULE_PATH}" - UPDATE_ALL_ARGS=() + UPDATE_ARGS=() if [ -n "$UPDATE_ALL" ]; then - UPDATE_ALL_ARGS=(--update-all "$UPDATE_ALL") + UPDATE_ARGS+=(--update-all "$UPDATE_ALL") + fi + if [ -n "$EXCLUDED_PACKAGES" ]; then + UPDATE_ARGS+=(--excluded-packages "$EXCLUDED_PACKAGES") fi - pixi run --manifest-path "${GITHUB_ACTION_PATH}/pyproject.toml" update-dependencies "${GITHUB_WORKSPACE}/${PROJECT_FILE_NAME}" "$SCHEDULE_PATH" "${UPDATE_ALL_ARGS[@]}" + pixi run --manifest-path "${GITHUB_ACTION_PATH}/pyproject.toml" update-dependencies "${GITHUB_WORKSPACE}/${PROJECT_FILE_NAME}" "$SCHEDULE_PATH" "${UPDATE_ARGS[@]}" - name: Changes id: changes shell: bash diff --git a/readme.md b/readme.md index eddb2dd..c2d71b2 100644 --- a/readme.md +++ b/readme.md @@ -50,11 +50,38 @@ The built-in `GITHUB_TOKEN` is used by default as long as the workflow has `pull | `pr_title` | no | `chore: Drop support for unsupported packages conform SPEC 0` | Title of the opened PR | | `commit_msg` | no | `chore: Drop support for unsupported packages conform SPEC 0` | Commit message for the version update commit | | `update_all` | no | — | If set to a number N, also update non-SPEC0 dependencies to versions released within the last N years (e.g. `2`) | +| `excluded_packages` | no | — | Comma- or whitespace-separated package names to leave unchanged, including with `update_all` | For examples of before/after see [tests/test_data/pyproject.toml](./tests/test_data/pyproject.toml) and [tests/test_data/pyproject_updated.toml](./tests/test_data/pyproject_updated.toml). SPEC 0 packages include `ipython`, `matplotlib`, `networkx`, `numpy`, `pandas`, `scikit-image`, `scikit-learn`, `scipy`, `xarray`, and `zarr`. +### Excluding packages + +If you want to exclude a package for example, you want to keep compatibility with NumPy 1.26 while updating the other dependencies, add `excluded_packages: "numpy"` to the action's `with` settings before its lower bound is raised. + +Separate names with commas or whitespace, including multiline YAML: + +```yaml +with: + update_all: 2 + excluded_packages: | + numpy, scikit-learn + python +``` + +Matching ignores case and treats dots, underscores, and hyphens as equivalent: `Scikit.Learn` matches `scikit-learn`. +Use bare package names; requirements such as `numpy>=1.26`, extras such as `numpy[extra]`, and wildcards are rejected. + +Exclusions take precedence over both the supplied schedule (including custom schedules) and `update_all`. +Excluding `python` also preserves `project.requires-python` and Pixi Python constraints; a missing `requires-python` stays absent. + +The CLI accepts the same syntax: + +```bash +python run_spec0_update.py pyproject.toml schedule.json --excluded-packages "numpy, python" +``` + ## Limitations 1. The action only tightens lower bounds and leaves upper bounds untouched. An update can produce an unsolvable environment — for example `numpy = ">=1.25.0,<2"` becomes `numpy = ">=2.0.0,<2"`. Keeping the environment solvable is out of scope; adjust upper bounds manually if needed. diff --git a/run_spec0_update.py b/run_spec0_update.py index 2031bfb..f9625d6 100644 --- a/run_spec0_update.py +++ b/run_spec0_update.py @@ -24,6 +24,12 @@ metavar="YEARS", help="Also update all non-SPEC0 dependencies to versions released within the last YEARS years (e.g., 2).", ) + parser.add_argument( + "--excluded-packages", + default="", + metavar="NAMES", + help="Leave these comma- or whitespace-separated package names unchanged; use python to exclude Python requirements.", + ) args = parser.parse_args() toml_path = Path(args.toml_path) schedule_path = Path(args.schedule_path) @@ -37,5 +43,10 @@ ) project_data = read_toml(toml_path) schedule_data = read_schedule(schedule_path) - update_pyproject_toml(project_data, schedule_data, update_all=args.update_all) + update_pyproject_toml( + project_data, + schedule_data, + update_all=args.update_all, + excluded_packages=args.excluded_packages.replace(",", " ").split(), + ) write_toml(toml_path, project_data) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..75cf2ad --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,61 @@ +from pathlib import Path +import json +import subprocess +import sys + +import pytest + +from spec0_action import read_toml + + +@pytest.mark.parametrize( + "excluded_packages", + ["numpy, PYTHON\nscikit-learn", "numpy>=1", "numpy[extra]", "numpy*"], +) +def test_cli_excluded_packages(tmp_path, excluded_packages): + project_path = tmp_path / "pyproject.toml" + original = """[project] +requires-python = ">= 3.9" +dependencies = ["numpy >= 1.26", "scikit-learn >= 1.0", "pandas>=1.0"] +""" + project_path.write_text(original) + schedule_path = tmp_path / "schedule.json" + schedule_path.write_text( + json.dumps( + [ + { + "start_date": "2000-01-01T00:00:00Z", + "packages": { + "numpy": "2.0", + "python": "3.12", + "scikit-learn": "1.4", + "pandas": "2.2", + }, + } + ] + ) + ) + + result = subprocess.run( + [ + sys.executable, + str(Path(__file__).resolve().parents[1] / "run_spec0_update.py"), + str(project_path), + str(schedule_path), + "--excluded-packages", + excluded_packages, + ], + capture_output=True, + text=True, + ) + + if excluded_packages.startswith("numpy,"): + assert result.returncode == 0, result.stderr + assert read_toml(project_path)["project"] == { + "requires-python": ">= 3.9", + "dependencies": ["numpy >= 1.26", "scikit-learn >= 1.0", "pandas>=2.2"], + } + else: + assert result.returncode != 0 + assert "name is invalid" in result.stderr + assert project_path.read_text() == original diff --git a/tests/test_spec0_versions.py b/tests/test_spec0_versions.py new file mode 100644 index 0000000..52bdbd3 --- /dev/null +++ b/tests/test_spec0_versions.py @@ -0,0 +1,52 @@ +import json +import runpy +from datetime import UTC, timedelta +from pathlib import Path +from unittest.mock import Mock + +import pandas as pd +import requests +from packaging.version import Version + + +def test_generator_uses_utc_dates_and_preserves_quarter_schedule(tmp_path, monkeypatch): + script = Path(__file__).resolve().parents[1] / "spec0_versions.py" + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + pd.Timestamp, "now", lambda tz=None: pd.Timestamp("2025-10-30", tz=tz) + ) + files = [ + {"filename": f"example-{version}-py3-none-any.whl", "upload-time": date} + for version, date in [ + ("1.0.0", "2023-07-15T00:00:00.000Z"), + ("1.1.0", "2024-01-10T00:00:00Z"), + ("1.2.0", "2024-07-01T00:00:00Z"), + ] + ] + get = Mock(return_value=Mock(json=Mock(return_value={"files": files}))) + monkeypatch.setattr(requests, "get", get) + + result = runpy.run_path(str(script)) + + assert get.call_count == len(result["CORE_PACKAGES"]) + assert result["CUTOFF"] == pd.Timestamp("2025-01-01", tz=UTC) + for releases in result["package_releases"].values(): + for dates in releases.values(): + assert dates["release_date"].utcoffset() == timedelta(0) + assert dates["drop_date"].utcoffset() == timedelta(0) + assert result["package_releases"]["numpy"][Version("1.0.0")][ + "release_date" + ] == pd.Timestamp("2023-07-15", tz=UTC) + schedule = { + entry["start_date"]: entry["packages"] + for entry in json.loads((tmp_path / "schedule.json").read_text()) + } + assert schedule["2025-07-01T00:00:00Z"] == { + package: "1.1.0" for package in result["CORE_PACKAGES"] + } + assert schedule["2026-01-01T00:00:00Z"] == { + package: "1.2.0" for package in result["CORE_PACKAGES"] + } + assert schedule["2025-10-01T00:00:00Z"] == {"python": "3.12"} + assert "gantt" in (tmp_path / "chart.md").read_text() + assert "2026 - Quarter 1" in (tmp_path / "schedule.md").read_text() From 225ea9d2a321f213216138e6a4c18f4d7bfaa574 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Sat, 5 Sep 2026 20:53:23 -0700 Subject: [PATCH 4/6] chore: why do i update pre-commit?? --- .pre-commit-config.yaml | 6 ++-- run_spec0_update.py | 4 +-- spec0_action/__init__.py | 37 ++++++++++++----------- spec0_action/parsing.py | 17 ++++++----- spec0_action/versions.py | 2 +- spec0_versions.py | 46 ++++++++++++++++------------- tests/test_cli.py | 3 +- tests/test_parsing.py | 8 +++-- tests/test_update_pyproject_toml.py | 32 ++++++++++++++++++-- tests/test_versions.py | 5 ++-- 10 files changed, 100 insertions(+), 60 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index de63cbe..99ee465 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,19 +15,19 @@ repos: - id: mixed-line-ending - id: trailing-whitespace - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.8.3 + rev: v3.9.6 hooks: - id: prettier files: \.(css|html|md|yml|yaml|gql) args: [--prose-wrap=preserve] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.14 + rev: v0.16.6 hooks: - id: ruff-check args: ["--fix", "--show-fixes", "--exit-non-zero-on-fix"] - id: ruff-format - repo: https://github.com/codespell-project/codespell - rev: "v2.4.2" + rev: "v2.4.3" hooks: - id: codespell diff --git a/run_spec0_update.py b/run_spec0_update.py index f9625d6..ca9312a 100644 --- a/run_spec0_update.py +++ b/run_spec0_update.py @@ -1,7 +1,7 @@ -from spec0_action import update_pyproject_toml, read_toml, write_toml, read_schedule -from pathlib import Path from argparse import ArgumentParser +from pathlib import Path +from spec0_action import read_schedule, read_toml, update_pyproject_toml, write_toml if __name__ == "__main__": parser = ArgumentParser( diff --git a/spec0_action/__init__.py b/spec0_action/__init__.py index fc73dae..b4ad72e 100644 --- a/spec0_action/__init__.py +++ b/spec0_action/__init__.py @@ -1,10 +1,18 @@ -from functools import cache -from packaging.specifiers import SpecifierSet -from typing import Callable, Sequence, Dict import datetime +from collections.abc import Callable, Sequence +from functools import cache + import requests +from packaging.specifiers import SpecifierSet +from packaging.utils import ( + InvalidSdistFilename, + InvalidWheelFilename, + canonicalize_name, + parse_sdist_filename, + parse_wheel_filename, +) +from packaging.version import Version -from spec0_action.versions import repr_spec_set, tighten_lower_bound from spec0_action.parsing import ( SupportSchedule, Url, @@ -15,16 +23,9 @@ read_toml, write_toml, ) -from packaging.version import Version -from packaging.utils import ( - InvalidSdistFilename, - InvalidWheelFilename, - canonicalize_name, - parse_sdist_filename, - parse_wheel_filename, -) +from spec0_action.versions import repr_spec_set, tighten_lower_bound -__all__ = ["read_schedule", "read_toml", "write_toml", "update_pyproject_toml"] +__all__ = ["read_schedule", "read_toml", "update_pyproject_toml", "write_toml"] @cache @@ -32,7 +33,7 @@ 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. """ - cutoff = datetime.datetime.now(tz=datetime.timezone.utc) - datetime.timedelta( + cutoff = datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta( days=int(365 * years) ) try: @@ -43,7 +44,7 @@ def _get_oldest_version_in_window(package: str, years: float) -> Version | None: ) resp.raise_for_status() data = resp.json() - except Exception: + except requests.RequestException: return None first_uploads: dict[Version, datetime.datetime] = {} for f in data.get("files", []): @@ -117,7 +118,7 @@ def iter_pep_dependency_lists(pyproject_data: dict): def update_dependency_table( - dep_table: dict, new_versions: Dict[str, Version], own_name: str | None + dep_table: dict, new_versions: dict[str, Version], own_name: str | None ): for pkg, pkg_data in dep_table.items(): package_key = canonicalize_name(pkg) @@ -145,7 +146,7 @@ def update_dependency_table( def update_pixi_dependencies( - pixi_tables: dict, new_versions: Dict[str, Version], own_name: str | None + pixi_tables: dict, new_versions: dict[str, Version], own_name: str | None ): for key in ("dependencies", "pypi-dependencies"): dep_table = pixi_tables.get(key) @@ -194,7 +195,7 @@ def update_pyproject_toml( ), key=lambda s: datetime.datetime.fromisoformat(s["start_date"]), ) - new_version: Dict[str, 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(): diff --git a/spec0_action/parsing.py b/spec0_action/parsing.py index 26a5558..e788c4e 100644 --- a/spec0_action/parsing.py +++ b/spec0_action/parsing.py @@ -1,12 +1,13 @@ -from typing import TypeAlias -from urllib.parse import ParseResult, urlparse -from tomlkit import dumps, loads import json -from packaging.specifiers import InvalidSpecifier, SpecifierSet -from packaging.version import InvalidVersion, Version -from typing import Dict, Sequence, Tuple, TypedDict +from collections.abc import Sequence from pathlib import Path from re import compile +from typing import TypeAlias, TypedDict +from urllib.parse import ParseResult, urlparse + +from packaging.specifiers import InvalidSpecifier, SpecifierSet +from packaging.version import InvalidVersion, Version +from tomlkit import dumps, loads # We won't actually do anything with URLs we just need to detect them Url: TypeAlias = ParseResult @@ -19,7 +20,7 @@ class SupportSchedule(TypedDict): start_date: str - packages: Dict[str, str] + packages: dict[str, str] def parse_version_spec(s: str) -> SpecifierSet: @@ -65,7 +66,7 @@ def read_schedule(path: Path | str) -> Sequence[SupportSchedule]: def parse_pep_dependency( dep_str: str, -) -> Tuple[str, str | None, SpecifierSet | Url | None, str | None]: +) -> tuple[str, str | None, SpecifierSet | Url | None, str | None]: match = PEP_PACKAGE_IDENT_RE.match(dep_str) if match is None: raise ValueError("Could not find any valid python package identifier") diff --git a/spec0_action/versions.py b/spec0_action/versions.py index 792dc7b..3798b8c 100644 --- a/spec0_action/versions.py +++ b/spec0_action/versions.py @@ -1,5 +1,5 @@ -from packaging.version import Version from packaging.specifiers import Specifier, SpecifierSet +from packaging.version import Version def tighten_lower_bound( diff --git a/spec0_versions.py b/spec0_versions.py index 4b7a0f8..894db50 100644 --- a/spec0_versions.py +++ b/spec0_versions.py @@ -1,11 +1,10 @@ -import requests -import json import collections -from datetime import datetime, timedelta +import json +from datetime import UTC, datetime, timedelta import pandas as pd -from packaging.version import Version, InvalidVersion - +import requests +from packaging.version import InvalidVersion, Version PY_RELEASES = { "3.8": "Oct 14, 2019", @@ -28,16 +27,16 @@ "xarray", "zarr", ] -PLUS_36_MONTHS = timedelta(days=int(365 * 3)) -PLUS_24_MONTHS = timedelta(days=int(365 * 2)) +PLUS_36_MONTHS = timedelta(days=365 * 3) +PLUS_24_MONTHS = timedelta(days=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_DATE = pd.Timestamp.now(tz=UTC) CURRENT_QUARTER_START = pd.Timestamp( - CURRENT_DATE.year, (CURRENT_DATE.quarter - 1) * 3 + 1, 1 + CURRENT_DATE.year, (CURRENT_DATE.quarter - 1) * 3 + 1, 1, tz=UTC ) CUTOFF = CURRENT_QUARTER_START - pd.DateOffset(months=9) @@ -65,14 +64,16 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS): 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) + release_date = datetime.strptime(f["upload-time"], format).replace( + tzinfo=UTC + ) 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()): + release_dates = {v: min(file_date[v]) for v in file_date} + for ver, release_date in sorted(release_dates.items()): drop_date = release_date + support_time if drop_date >= CUTOFF: releases[ver] = { @@ -85,8 +86,13 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS): package_releases = { "python": { version: { - "release_date": datetime.strptime(release_date, "%b %d, %Y"), - "drop_date": datetime.strptime(release_date, "%b %d, %Y") + PLUS_36_MONTHS, + "release_date": datetime.strptime(release_date, "%b %d, %Y").replace( + tzinfo=UTC + ), + "drop_date": datetime.strptime(release_date, "%b %d, %Y").replace( + tzinfo=UTC + ) + + PLUS_36_MONTHS, } for version, release_date in PY_RELEASES.items() } @@ -113,10 +119,10 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS): ) for name, releases in package_releases.items(): fh.write(f"\n\nsection {name}") - for version, dates in releases.items(): - fh.write( - f"\n{version} : {dates['release_date'].strftime('%Y-%m-%d')},{dates['drop_date'].strftime('%Y-%m-%d')}" - ) + fh.writelines( + f"\n{version} : {dates['release_date'].strftime('%Y-%m-%d')},{dates['drop_date'].strftime('%Y-%m-%d')}" + for version, dates in releases.items() + ) fh.write("\n") # Print drop schedule @@ -132,7 +138,7 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS): ) ) df = pd.DataFrame(data, columns=["package", "version", "release", "drop"]) -df["quarter"] = df["drop"].dt.to_period("Q") +df["quarter"] = df["drop"].dt.tz_localize(None).dt.to_period("Q") df["new_min_version"] = ( df[["package", "version", "quarter"]].groupby("package").shift(-1)["version"] ) @@ -208,7 +214,7 @@ def make_quarter(quarter, dq): # 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:]: + for quarter in sorted(set(dq.index.get_level_values(0)))[1:]: tb.append(make_quarter(quarter, dq)) fh.write("\n\n".join(tb)) fh.write("\n") diff --git a/tests/test_cli.py b/tests/test_cli.py index 75cf2ad..70e27d1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,7 +1,7 @@ -from pathlib import Path import json import subprocess import sys +from pathlib import Path import pytest @@ -47,6 +47,7 @@ def test_cli_excluded_packages(tmp_path, excluded_packages): ], capture_output=True, text=True, + check=False, ) if excluded_packages.startswith("numpy,"): diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 515e249..7abda69 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -1,8 +1,10 @@ -from spec0_action.parsing import parse_version_spec, parse_pep_dependency -from packaging.specifiers import SpecifierSet -import pytest from urllib.parse import urlparse +import pytest +from packaging.specifiers import SpecifierSet + +from spec0_action.parsing import parse_pep_dependency, parse_version_spec + URL = "https://github.com/pypa/pip/archive/1.3.1.zip#sha1=da9234ee9982d4bbb3c72346a6de940a148ea686" diff --git a/tests/test_update_pyproject_toml.py b/tests/test_update_pyproject_toml.py index eb79911..fa797d5 100644 --- a/tests/test_update_pyproject_toml.py +++ b/tests/test_update_pyproject_toml.py @@ -6,9 +6,9 @@ from packaging.version import Version from tomlkit import dumps -from spec0_action.parsing import read_schedule, read_toml -from spec0_action import update_pyproject_toml import spec0_action +from spec0_action import update_pyproject_toml +from spec0_action.parsing import read_schedule, read_toml # Fixed time to avoid test results changing over time FAKE_TIME = datetime.datetime(2025, 10, 30, 0, 0, 0, tzinfo=datetime.UTC) @@ -321,6 +321,34 @@ def test_update_all_uses_version_release_date_not_new_file_upload(patch_datetime ) +@pytest.mark.parametrize( + ("stage", "error"), + [ + ("request", spec0_action.requests.ConnectionError("offline")), + ("request", spec0_action.requests.Timeout("timed out")), + ("status", spec0_action.requests.HTTPError("server error")), + ( + "json", + spec0_action.requests.exceptions.JSONDecodeError("invalid JSON", "", 0), + ), + ], +) +def test_update_all_preserves_dependency_on_pypi_failure( + patch_datetime_now, schedule, stage, error +): + pyproject = _minimal_pyproject("requests >= 2.0") + with patch.object(spec0_action.requests, "get") as get: + operation = { + "request": get, + "status": get.return_value.raise_for_status, + "json": get.return_value.json, + }[stage] + operation.side_effect = error + update_pyproject_toml(pyproject, schedule, update_all=2.0) + + assert pyproject["project"]["dependencies"] == ["requests >= 2.0"] + + def test_update_all_queries_pypi_once_per_package(patch_datetime_now, schedule): requested_urls = [] diff --git a/tests/test_versions.py b/tests/test_versions.py index 302dee6..92f8c98 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -1,7 +1,8 @@ +import pytest +from packaging.specifiers import SpecifierSet from packaging.version import Version + from spec0_action.versions import repr_spec_set, tighten_lower_bound -from packaging.specifiers import SpecifierSet -import pytest def test_repr_specset(): From 2d6c85cfbcb6999407a2baa0a5c5a19efafd3da0 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Sat, 5 Sep 2026 23:17:00 -0700 Subject: [PATCH 5/6] tests: prune --- tests/test_cli.py | 2 +- tests/test_update_pyproject_toml.py | 54 ++--------------------------- 2 files changed, 3 insertions(+), 53 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 70e27d1..245c589 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,7 +10,7 @@ @pytest.mark.parametrize( "excluded_packages", - ["numpy, PYTHON\nscikit-learn", "numpy>=1", "numpy[extra]", "numpy*"], + ["numpy, PYTHON\nscikit-learn", "numpy>=1"], ) def test_cli_excluded_packages(tmp_path, excluded_packages): project_path = tmp_path / "pyproject.toml" diff --git a/tests/test_update_pyproject_toml.py b/tests/test_update_pyproject_toml.py index fa797d5..d94f94f 100644 --- a/tests/test_update_pyproject_toml.py +++ b/tests/test_update_pyproject_toml.py @@ -76,8 +76,9 @@ def test_update_pyproject_toml_with_pixi(patch_datetime_now, schedule): def test_update_all_updates_non_spec0_package(patch_datetime_now, schedule): pyproject = _minimal_pyproject("requests>=2.0.0", "numpy>=1.10.0") - with _mock_pypi("2.28.0"): + with _mock_pypi("2.28.0") as mock_pypi: update_pyproject_toml(pyproject, schedule, update_all=2.0) + mock_pypi.assert_called_once_with("requests", 2.0) # requests is not in SPEC 0 and is bumped from PyPI, numpy from the schedule assert pyproject["project"]["dependencies"] == [ "requests>=2.28.0", @@ -85,14 +86,6 @@ def test_update_all_updates_non_spec0_package(patch_datetime_now, schedule): ] -def test_update_all_skips_spec0_packages(patch_datetime_now, schedule): - pyproject = _minimal_pyproject("numpy>=1.10.0") - with _mock_pypi() as mock_pypi: - update_pyproject_toml(pyproject, schedule, update_all=2.0) - # numpy is in the SPEC 0 schedule, PyPI must not be queried for it - mock_pypi.assert_not_called() - - def test_update_all_skips_already_strict_bound(patch_datetime_now, schedule): # PyPI returns an older version than what's already pinned, the bound must not regress pyproject = _minimal_pyproject("requests>=2.32.0") @@ -199,26 +192,6 @@ def test_canonical_package_names_match_schedule(patch_datetime_now, schedule): ] -def test_optional_dependencies_and_dependency_groups_are_updated( - patch_datetime_now, schedule -): - pyproject = _minimal_pyproject() - pyproject["project"]["optional-dependencies"] = { - "test": ["Numpy>=1.20"], - } - pyproject["dependency-groups"] = { - "dev": ["numpy>=1.20", {"include-group": "test"}], - } - - update_pyproject_toml(pyproject, schedule) - - assert pyproject["project"]["optional-dependencies"]["test"] == ["Numpy>=2.0.0"] - assert pyproject["dependency-groups"]["dev"] == [ - "numpy>=2.0.0", - {"include-group": "test"}, - ] - - def test_url_pinned_and_up_to_date_dependencies_left_untouched( patch_datetime_now, schedule ): @@ -274,29 +247,6 @@ def test_pixi_feature_pypi_dependencies_and_non_version_tables( } -def test_pixi_target_dependencies_are_updated(patch_datetime_now, schedule): - pyproject = _minimal_pyproject() - pyproject["tool"] = { - "pixi": { - "target": {"linux-64": {"dependencies": {"numpy": ">=1.20"}}}, - "feature": { - "test": { - "target": {"osx-arm64": {"pypi-dependencies": {"numpy": ">=1.20"}}} - } - }, - } - } - - update_pyproject_toml(pyproject, schedule) - - pixi = pyproject["tool"]["pixi"] - assert pixi["target"]["linux-64"]["dependencies"]["numpy"] == ">=2.0.0" - assert ( - pixi["feature"]["test"]["target"]["osx-arm64"]["pypi-dependencies"]["numpy"] - == ">=2.0.0" - ) - - def test_update_all_uses_version_release_date_not_new_file_upload(patch_datetime_now): response = _pypi_response( [ From 42d7bee4e1be226de5ca9a7b62e3a05253a3bcfa Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Sat, 5 Sep 2026 23:53:11 -0700 Subject: [PATCH 6/6] tests: prune even more --- .github/workflows/test_action.yaml | 28 +--- action.yaml | 9 +- pyproject.toml | 4 + readme.md | 16 +- spec0_action/__init__.py | 37 ++--- spec0_action/parsing.py | 4 +- spec0_versions.py | 21 +-- tests/test_cli.py | 93 ++++++----- tests/test_parsing.py | 10 +- tests/test_spec0_versions.py | 37 ++--- tests/test_update_pyproject_toml.py | 231 ++++++++-------------------- tests/test_versions.py | 2 - 12 files changed, 165 insertions(+), 327 deletions(-) diff --git a/.github/workflows/test_action.yaml b/.github/workflows/test_action.yaml index 9e05105..e539b83 100644 --- a/.github/workflows/test_action.yaml +++ b/.github/workflows/test_action.yaml @@ -35,24 +35,10 @@ jobs: env: EXCLUDED_PACKAGES: ${{ matrix.excluded_packages }} run: | - pixi run python - <<'PY' - import os - import subprocess - import tomllib - from pathlib import Path - - path = "tests/test_data/pyproject.toml" - original = tomllib.loads(subprocess.check_output(["git", "show", f"HEAD:{path}"], text=True)) - updated = tomllib.loads(Path(path).read_text()) - before = original["project"]["dependencies"] - after = updated["project"]["dependencies"] - for package in ("numpy", "scikit-learn"): - old = [dep for dep in before if dep.startswith(package)] - new = [dep for dep in after if dep.startswith(package)] - assert old and new - if os.environ["EXCLUDED_PACKAGES"]: - assert new == old, (old, new) - else: - assert new != old, (old, new) - assert [dep for dep in after if dep.startswith("pandas")] != [dep for dep in before if dep.startswith("pandas")] - PY + git diff -U0 -- tests/test_data/pyproject.toml > changes.diff + grep -q '^+.*pandas' changes.diff + if [ -n "$EXCLUDED_PACKAGES" ]; then + if grep -Eq '^[-+].*(numpy|scikit-learn)' changes.diff; then exit 1; fi + else + grep -q '^+.*numpy' changes.diff + fi diff --git a/action.yaml b/action.yaml index 7d79b4a..4c8d316 100644 --- a/action.yaml +++ b/action.yaml @@ -79,14 +79,11 @@ runs: SCHEDULE_PATH="${GITHUB_WORKSPACE}/schedule.json" fi echo "Updating ${PROJECT_FILE_NAME} using schedule ${SCHEDULE_PATH}" - UPDATE_ARGS=() + UPDATE_ALL_ARGS=() if [ -n "$UPDATE_ALL" ]; then - UPDATE_ARGS+=(--update-all "$UPDATE_ALL") + UPDATE_ALL_ARGS=(--update-all "$UPDATE_ALL") fi - if [ -n "$EXCLUDED_PACKAGES" ]; then - UPDATE_ARGS+=(--excluded-packages "$EXCLUDED_PACKAGES") - fi - pixi run --manifest-path "${GITHUB_ACTION_PATH}/pyproject.toml" update-dependencies "${GITHUB_WORKSPACE}/${PROJECT_FILE_NAME}" "$SCHEDULE_PATH" "${UPDATE_ARGS[@]}" + pixi run --manifest-path "${GITHUB_ACTION_PATH}/pyproject.toml" update-dependencies "${GITHUB_WORKSPACE}/${PROJECT_FILE_NAME}" "$SCHEDULE_PATH" --excluded-packages "$EXCLUDED_PACKAGES" "${UPDATE_ALL_ARGS[@]}" - name: Changes id: changes shell: bash diff --git a/pyproject.toml b/pyproject.toml index e822daa..5cdf08b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,3 +29,7 @@ pytest = "*" [tool.pixi.environments] test = ["test"] + +[tool.ruff.lint] +# Naive datetimes are fine: PyPI upload times are UTC and the schedule is quarter-granular +ignore = ["DTZ"] diff --git a/readme.md b/readme.md index c2d71b2..2349689 100644 --- a/readme.md +++ b/readme.md @@ -58,9 +58,7 @@ SPEC 0 packages include `ipython`, `matplotlib`, `networkx`, `numpy`, `pandas`, ### Excluding packages -If you want to exclude a package for example, you want to keep compatibility with NumPy 1.26 while updating the other dependencies, add `excluded_packages: "numpy"` to the action's `with` settings before its lower bound is raised. - -Separate names with commas or whitespace, including multiline YAML: +To keep a package's lower bound as-is, for example to stay compatible with NumPy 1.26 while everything else updates, list it in `excluded_packages` before the action raises its bound (bounds are never lowered). Separate names with commas or whitespace; `python` excludes the Python requirement: ```yaml with: @@ -70,17 +68,7 @@ with: python ``` -Matching ignores case and treats dots, underscores, and hyphens as equivalent: `Scikit.Learn` matches `scikit-learn`. -Use bare package names; requirements such as `numpy>=1.26`, extras such as `numpy[extra]`, and wildcards are rejected. - -Exclusions take precedence over both the supplied schedule (including custom schedules) and `update_all`. -Excluding `python` also preserves `project.requires-python` and Pixi Python constraints; a missing `requires-python` stays absent. - -The CLI accepts the same syntax: - -```bash -python run_spec0_update.py pyproject.toml schedule.json --excluded-packages "numpy, python" -``` +Exclusions win over the schedule and `update_all`. The CLI takes the same value via `--excluded-packages`. ## Limitations diff --git a/spec0_action/__init__.py b/spec0_action/__init__.py index b4ad72e..9c2ff4b 100644 --- a/spec0_action/__init__.py +++ b/spec0_action/__init__.py @@ -84,7 +84,7 @@ def _version_from_filename(filename: str) -> Version | None: def update_pyproject_dependencies( dependencies: list, resolve_lower_bound: Callable[[str], Version | None], - own_name: str | None, + skip: set[str], ): # Assign by index so the (tomlkit) list is updated in place for i, dep_str in enumerate(dependencies): @@ -92,7 +92,7 @@ def update_pyproject_dependencies( continue pkg, extras, spec, env = parse_pep_dependency(dep_str) package_key = canonicalize_name(pkg) - if isinstance(spec, Url) or package_key == own_name: + if isinstance(spec, Url) or package_key in skip: continue new_lower_bound = resolve_lower_bound(package_key) if new_lower_bound is None: @@ -118,11 +118,11 @@ def iter_pep_dependency_lists(pyproject_data: dict): def update_dependency_table( - dep_table: dict, new_versions: dict[str, Version], own_name: str | None + dep_table: dict, new_versions: dict[str, Version], skip: set[str] ): for pkg, pkg_data in dep_table.items(): package_key = canonicalize_name(pkg) - if package_key == own_name or package_key not in new_versions: + if package_key in skip or package_key not in new_versions: continue # Like pkg = ">x.y.z, Version | None: - if package_key in excluded: - return None if package_key in new_version: return new_version[package_key] if update_all is not None: @@ -228,7 +223,7 @@ def resolve_lower_bound(package_key: str) -> Version | None: return None for dependencies in iter_pep_dependency_lists(pyproject_data): - update_pyproject_dependencies(dependencies, resolve_lower_bound, own_name) + update_pyproject_dependencies(dependencies, resolve_lower_bound, skip) if "tool" in pyproject_data and "pixi" in pyproject_data["tool"]: - update_pixi_dependencies(pyproject_data["tool"]["pixi"], new_version, own_name) + update_pixi_dependencies(pyproject_data["tool"]["pixi"], new_version, skip) diff --git a/spec0_action/parsing.py b/spec0_action/parsing.py index e788c4e..6ab0976 100644 --- a/spec0_action/parsing.py +++ b/spec0_action/parsing.py @@ -54,13 +54,13 @@ def write_toml(path: Path | str, data: dict): def read_toml(path: Path | str) -> dict: - with open(path, "r") as file: + with open(path) as file: contents = file.read() return loads(contents) def read_schedule(path: Path | str) -> Sequence[SupportSchedule]: - with open(path, "r") as file: + with open(path) as file: return json.load(file) diff --git a/spec0_versions.py b/spec0_versions.py index 894db50..457cfab 100644 --- a/spec0_versions.py +++ b/spec0_versions.py @@ -1,6 +1,6 @@ import collections import json -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta import pandas as pd import requests @@ -34,9 +34,9 @@ # 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(tz=UTC) +CURRENT_DATE = pd.Timestamp.now(tz="UTC").tz_localize(None) CURRENT_QUARTER_START = pd.Timestamp( - CURRENT_DATE.year, (CURRENT_DATE.quarter - 1) * 3 + 1, 1, tz=UTC + CURRENT_DATE.year, (CURRENT_DATE.quarter - 1) * 3 + 1, 1 ) CUTOFF = CURRENT_QUARTER_START - pd.DateOffset(months=9) @@ -64,9 +64,7 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS): 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).replace( - tzinfo=UTC - ) + release_date = datetime.strptime(f["upload-time"], format) except ValueError as e: print(f"Error parsing invalid date: {e}") if not release_date: @@ -86,13 +84,8 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS): package_releases = { "python": { version: { - "release_date": datetime.strptime(release_date, "%b %d, %Y").replace( - tzinfo=UTC - ), - "drop_date": datetime.strptime(release_date, "%b %d, %Y").replace( - tzinfo=UTC - ) - + PLUS_36_MONTHS, + "release_date": datetime.strptime(release_date, "%b %d, %Y"), + "drop_date": datetime.strptime(release_date, "%b %d, %Y") + PLUS_36_MONTHS, } for version, release_date in PY_RELEASES.items() } @@ -138,7 +131,7 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS): ) ) df = pd.DataFrame(data, columns=["package", "version", "release", "drop"]) -df["quarter"] = df["drop"].dt.tz_localize(None).dt.to_period("Q") +df["quarter"] = df["drop"].dt.to_period("Q") df["new_min_version"] = ( df[["package", "version", "quarter"]].groupby("package").shift(-1)["version"] ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 245c589..023f30f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,4 @@ -import json -import subprocess +import runpy import sys from pathlib import Path @@ -7,56 +6,52 @@ from spec0_action import read_toml +SCRIPT = Path(__file__).resolve().parents[1] / "run_spec0_update.py" +SCHEDULE = '[{"start_date": "2000-01-01T00:00:00Z", "packages": {"numpy": "2.0", "python": "3.12", "scikit-learn": "1.4", "pandas": "2.2"}}]' +PROJECT = '[project]\nrequires-python = ">= 3.9"\ndependencies = ["numpy >= 1.26", "scikit-learn >= 1.0", "pandas>=1.0"]\n' + @pytest.mark.parametrize( - "excluded_packages", - ["numpy, PYTHON\nscikit-learn", "numpy>=1"], + ("excluded", "requires_python", "dependencies"), + [ + ("", ">=3.12", ["numpy>=2.0", "scikit-learn>=1.4", "pandas>=2.2"]), + ( + "numpy, PYTHON\nscikit-learn", + ">= 3.9", + ["numpy >= 1.26", "scikit-learn >= 1.0", "pandas>=2.2"], + ), + ], ) -def test_cli_excluded_packages(tmp_path, excluded_packages): - project_path = tmp_path / "pyproject.toml" - original = """[project] -requires-python = ">= 3.9" -dependencies = ["numpy >= 1.26", "scikit-learn >= 1.0", "pandas>=1.0"] -""" - project_path.write_text(original) - schedule_path = tmp_path / "schedule.json" - schedule_path.write_text( - json.dumps( - [ - { - "start_date": "2000-01-01T00:00:00Z", - "packages": { - "numpy": "2.0", - "python": "3.12", - "scikit-learn": "1.4", - "pandas": "2.2", - }, - } - ] - ) - ) +def test_cli_excluded_packages( + tmp_path, monkeypatch, excluded, requires_python, dependencies +): + project = tmp_path / "pyproject.toml" + project.write_text(PROJECT) + schedule = tmp_path / "schedule.json" + schedule.write_text(SCHEDULE) + argv = [str(SCRIPT), str(project), str(schedule), "--excluded-packages", excluded] + monkeypatch.setattr(sys, "argv", argv) + + runpy.run_path(str(SCRIPT), run_name="__main__") - result = subprocess.run( - [ - sys.executable, - str(Path(__file__).resolve().parents[1] / "run_spec0_update.py"), - str(project_path), - str(schedule_path), - "--excluded-packages", - excluded_packages, - ], - capture_output=True, - text=True, - check=False, + assert read_toml(project)["project"] == { + "requires-python": requires_python, + "dependencies": dependencies, + } + + +def test_cli_invalid_exclusion_preserves_file(tmp_path, monkeypatch): + project = tmp_path / "pyproject.toml" + project.write_text(PROJECT) + schedule = tmp_path / "schedule.json" + schedule.write_text(SCHEDULE) + monkeypatch.setattr( + sys, + "argv", + [str(SCRIPT), str(project), str(schedule), "--excluded-packages", "numpy>=1"], ) - if excluded_packages.startswith("numpy,"): - assert result.returncode == 0, result.stderr - assert read_toml(project_path)["project"] == { - "requires-python": ">= 3.9", - "dependencies": ["numpy >= 1.26", "scikit-learn >= 1.0", "pandas>=2.2"], - } - else: - assert result.returncode != 0 - assert "name is invalid" in result.stderr - assert project_path.read_text() == original + with pytest.raises(ValueError, match="invalid"): + runpy.run_path(str(SCRIPT), run_name="__main__") + + assert project.read_bytes() == PROJECT.encode() diff --git a/tests/test_parsing.py b/tests/test_parsing.py index 7abda69..8eb53bf 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -14,7 +14,7 @@ ("*", SpecifierSet(">=0")), (">4, <9", SpecifierSet(">4,<9")), (">=4", SpecifierSet(">=4")), - (">=2025.7", SpecifierSet(">=2025.7")), + ("1.2.3", SpecifierSet(">=1.2.3")), ("3.11.*", SpecifierSet("==3.11.*")), ], ) @@ -33,19 +33,11 @@ def test_parse_version_spec_invalid(spec_str): [ ("matplotlib", ("matplotlib", None, None, None)), ("ruamel.yaml", ("ruamel.yaml", None, None, None)), - ( - "matplotlib>=3.7.0,<4", - ("matplotlib", None, SpecifierSet(">=3.7.0,<4"), None), - ), ("matplotlib >= 3.7.0", ("matplotlib", None, SpecifierSet(">=3.7.0"), None)), ( "matplotlib[foo,bar]>=3.7.0,<4", ("matplotlib", "[foo,bar]", SpecifierSet(">=3.7.0,<4"), None), ), - ( - "matplotlib>=3.7.0,<4,!=3.8.14", - ("matplotlib", None, SpecifierSet("!=3.8.14,<4,>=3.7.0"), None), - ), ( "matplotlib>=3.7.0,<4;sys_platform != 'win32'", ( diff --git a/tests/test_spec0_versions.py b/tests/test_spec0_versions.py index 52bdbd3..38c7220 100644 --- a/tests/test_spec0_versions.py +++ b/tests/test_spec0_versions.py @@ -1,20 +1,22 @@ import json import runpy -from datetime import UTC, timedelta from pathlib import Path from unittest.mock import Mock import pandas as pd import requests -from packaging.version import Version -def test_generator_uses_utc_dates_and_preserves_quarter_schedule(tmp_path, monkeypatch): +def test_generator_schedule(tmp_path, monkeypatch): script = Path(__file__).resolve().parents[1] / "spec0_versions.py" monkeypatch.chdir(tmp_path) - monkeypatch.setattr( - pd.Timestamp, "now", lambda tz=None: pd.Timestamp("2025-10-30", tz=tz) - ) + + def now(tz=None): + # Local time is still in Q3 while UTC has reached Q4. + instant = pd.Timestamp("2025-09-30T17:30:00-07:00") + return instant.tz_convert(tz) if tz else instant.tz_localize(None) + + monkeypatch.setattr(pd.Timestamp, "now", now) files = [ {"filename": f"example-{version}-py3-none-any.whl", "upload-time": date} for version, date in [ @@ -23,30 +25,19 @@ def test_generator_uses_utc_dates_and_preserves_quarter_schedule(tmp_path, monke ("1.2.0", "2024-07-01T00:00:00Z"), ] ] - get = Mock(return_value=Mock(json=Mock(return_value={"files": files}))) - monkeypatch.setattr(requests, "get", get) + response = Mock(json=Mock(return_value={"files": files})) + monkeypatch.setattr(requests, "get", Mock(return_value=response)) result = runpy.run_path(str(script)) - assert get.call_count == len(result["CORE_PACKAGES"]) - assert result["CUTOFF"] == pd.Timestamp("2025-01-01", tz=UTC) - for releases in result["package_releases"].values(): - for dates in releases.values(): - assert dates["release_date"].utcoffset() == timedelta(0) - assert dates["drop_date"].utcoffset() == timedelta(0) - assert result["package_releases"]["numpy"][Version("1.0.0")][ - "release_date" - ] == pd.Timestamp("2023-07-15", tz=UTC) schedule = { entry["start_date"]: entry["packages"] for entry in json.loads((tmp_path / "schedule.json").read_text()) } - assert schedule["2025-07-01T00:00:00Z"] == { - package: "1.1.0" for package in result["CORE_PACKAGES"] - } - assert schedule["2026-01-01T00:00:00Z"] == { - package: "1.2.0" for package in result["CORE_PACKAGES"] - } + assert min(schedule) == "2025-07-01T00:00:00Z" + core = result["CORE_PACKAGES"] + assert schedule["2025-07-01T00:00:00Z"] == dict.fromkeys(core, "1.1.0") + assert schedule["2026-01-01T00:00:00Z"] == dict.fromkeys(core, "1.2.0") assert schedule["2025-10-01T00:00:00Z"] == {"python": "3.12"} assert "gantt" in (tmp_path / "chart.md").read_text() assert "2026 - Quarter 1" in (tmp_path / "schedule.md").read_text() diff --git a/tests/test_update_pyproject_toml.py b/tests/test_update_pyproject_toml.py index d94f94f..55316a0 100644 --- a/tests/test_update_pyproject_toml.py +++ b/tests/test_update_pyproject_toml.py @@ -1,10 +1,9 @@ import datetime from copy import deepcopy -from unittest.mock import patch +from unittest.mock import Mock, call, patch import pytest from packaging.version import Version -from tomlkit import dumps import spec0_action from spec0_action import update_pyproject_toml @@ -49,58 +48,37 @@ def _mock_pypi(version=None): def _pypi_response(files): - class Response: - def raise_for_status(self): - pass + return Mock(json=Mock(return_value={"files": files})) - def json(self): - return {"files": files} - return Response() - - -def test_update_pyproject_toml(patch_datetime_now, schedule): - expected = read_toml("tests/test_data/pyproject_updated.toml") - pyproject_data = read_toml("tests/test_data/pyproject.toml") - update_pyproject_toml(pyproject_data, schedule) - - assert pyproject_data == expected - - -def test_update_pyproject_toml_with_pixi(patch_datetime_now, schedule): - expected = read_toml("tests/test_data/pyproject_pixi_updated.toml") - pyproject_data = read_toml("tests/test_data/pyproject_pixi.toml") - update_pyproject_toml(pyproject_data, schedule) - assert pyproject_data == expected +@pytest.mark.parametrize("name", ["pyproject", "pyproject_pixi"]) +def test_update_pyproject_toml(patch_datetime_now, schedule, name): + pyproject = read_toml(f"tests/test_data/{name}.toml") + update_pyproject_toml(pyproject, schedule) + assert pyproject == read_toml(f"tests/test_data/{name}_updated.toml") -def test_update_all_updates_non_spec0_package(patch_datetime_now, schedule): - pyproject = _minimal_pyproject("requests>=2.0.0", "numpy>=1.10.0") +@pytest.mark.parametrize( + ("update_all", "expected"), [(None, "requests>=2.0.0"), (2.0, "requests>=2.28.0")] +) +def test_update_all_controls_pypi_fallback( + patch_datetime_now, schedule, update_all, expected +): + # Non-SPEC 0 packages come from PyPI only when update_all is set; schedule + # packages never do, and keep their original spelling + pyproject = _minimal_pyproject( + "requests>=2.0.0", "Numpy>=1.10.0", "scikit_learn>=1.0" + ) with _mock_pypi("2.28.0") as mock_pypi: - update_pyproject_toml(pyproject, schedule, update_all=2.0) - mock_pypi.assert_called_once_with("requests", 2.0) - # requests is not in SPEC 0 and is bumped from PyPI, numpy from the schedule + update_pyproject_toml(pyproject, schedule, update_all=update_all) + assert mock_pypi.call_args_list == ([call("requests", 2.0)] if update_all else []) assert pyproject["project"]["dependencies"] == [ - "requests>=2.28.0", - "numpy>=2.0.0", + expected, + "Numpy>=2.0.0", + "scikit_learn>=1.4.0", ] -def test_update_all_skips_already_strict_bound(patch_datetime_now, schedule): - # PyPI returns an older version than what's already pinned, the bound must not regress - pyproject = _minimal_pyproject("requests>=2.32.0") - with _mock_pypi("2.28.0"): - update_pyproject_toml(pyproject, schedule, update_all=2.0) - assert pyproject["project"]["dependencies"] == ["requests>=2.32.0"] - - -def test_update_all_noop_when_not_set(patch_datetime_now, schedule): - pyproject = _minimal_pyproject("requests>=2.0.0", "numpy>=1.10.0") - with _mock_pypi() as mock_pypi: - update_pyproject_toml(pyproject, schedule) - mock_pypi.assert_not_called() - - def test_update_all_updates_optional_dependency_groups_and_unbounded( patch_datetime_now, schedule ): @@ -122,30 +100,19 @@ def test_update_all_updates_optional_dependency_groups_and_unbounded( ] -def test_self_referencing_extras_are_left_alone(patch_datetime_now, schedule): - pyproject = _minimal_pyproject("requests>=2.0.0") - pyproject["project"]["name"] = "My_Package" - pyproject["dependency-groups"] = { - "tests": ["my-package[plotting,tests-only]"], - } +@pytest.mark.parametrize("name", ["My_Package", "numpy", "Python"]) +def test_self_reference_left_alone(patch_datetime_now, schedule, name): + # Self-references like "pkg[extras]" share extras between groups; never pin + # them, even with update_all or when the project is named like a schedule package + dep = f"{name.lower()}[plotting,tests-only]" + pyproject = _minimal_pyproject("requests>=2.0.0", dep) + pyproject["project"]["name"] = name with _mock_pypi("2.2.2") as mock_pypi: update_pyproject_toml(pyproject, schedule, update_all=2.0) - assert pyproject["dependency-groups"]["tests"] == [ - "my-package[plotting,tests-only]" - ] - for call_args in mock_pypi.call_args_list: - assert call_args[0][0] != "my-package" - - -def test_self_reference_skipped_even_when_in_schedule(patch_datetime_now, schedule): - # A project named like a schedule package must not have its self-reference pinned - pyproject = _minimal_pyproject("numpy[test]") - pyproject["project"]["name"] = "numpy" - - update_pyproject_toml(pyproject, schedule) - - assert pyproject["project"]["dependencies"] == ["numpy[test]"] + mock_pypi.assert_called_once_with("requests", 2.0) + assert pyproject["project"]["dependencies"] == ["requests>=2.2.2", dep] + assert pyproject["project"]["requires-python"] == ">=3.12" @pytest.mark.parametrize( @@ -162,34 +129,14 @@ def test_self_reference_skipped_even_when_in_schedule(patch_datetime_now, schedu ], ) def test_requires_python(patch_datetime_now, schedule, current, expected): - pyproject = _minimal_pyproject() - if current is None: - del pyproject["project"]["requires-python"] - else: + # No dependencies table at all: only requires-python is touched + pyproject = {"project": {}} + if current: pyproject["project"]["requires-python"] = current update_pyproject_toml(pyproject, schedule) - assert pyproject["project"]["requires-python"] == expected - - -def test_missing_project_dependencies_is_noop(patch_datetime_now, schedule): - pyproject = {"project": {"requires-python": ">=3.9"}} - - update_pyproject_toml(pyproject, schedule) - - assert pyproject["project"]["requires-python"] == ">=3.12" - - -def test_canonical_package_names_match_schedule(patch_datetime_now, schedule): - pyproject = _minimal_pyproject("Numpy>=1.20", "scikit_learn>=1.0") - - update_pyproject_toml(pyproject, schedule) - - assert pyproject["project"]["dependencies"] == [ - "Numpy>=2.0.0", - "scikit_learn>=1.4.0", - ] + assert pyproject["project"] == {"requires-python": expected} def test_url_pinned_and_up_to_date_dependencies_left_untouched( @@ -274,9 +221,8 @@ def test_update_all_uses_version_release_date_not_new_file_upload(patch_datetime @pytest.mark.parametrize( ("stage", "error"), [ - ("request", spec0_action.requests.ConnectionError("offline")), - ("request", spec0_action.requests.Timeout("timed out")), - ("status", spec0_action.requests.HTTPError("server error")), + ("get", spec0_action.requests.ConnectionError("offline")), + ("raise_for_status", spec0_action.requests.HTTPError("server error")), ( "json", spec0_action.requests.exceptions.JSONDecodeError("invalid JSON", "", 0), @@ -288,11 +234,7 @@ def test_update_all_preserves_dependency_on_pypi_failure( ): pyproject = _minimal_pyproject("requests >= 2.0") with patch.object(spec0_action.requests, "get") as get: - operation = { - "request": get, - "status": get.return_value.raise_for_status, - "json": get.return_value.json, - }[stage] + operation = get if stage == "get" else getattr(get.return_value, stage) operation.side_effect = error update_pyproject_toml(pyproject, schedule, update_all=2.0) @@ -300,27 +242,23 @@ def test_update_all_preserves_dependency_on_pypi_failure( def test_update_all_queries_pypi_once_per_package(patch_datetime_now, schedule): - requested_urls = [] - - def fake_get(url, **kwargs): - requested_urls.append(url) - return _pypi_response( - [ - { - "filename": "demo_pkg-2.0.0-py3-none-any.whl", - "upload-time": "2025-01-01T00:00:00Z", - } - ] - ) - + response = _pypi_response( + [ + { + "filename": "demo_pkg-2.0.0-py3-none-any.whl", + "upload-time": "2025-01-01T00:00:00Z", + } + ] + ) pyproject = _minimal_pyproject("Demo_Pkg>=1.0.0") pyproject["dependency-groups"] = {"dev": ["demo-pkg>=1.0.0"]} - with patch.object(spec0_action.requests, "get", side_effect=fake_get): + with patch.object(spec0_action.requests, "get", return_value=response) as get: update_pyproject_toml(pyproject, schedule, update_all=2.0) # Both spellings canonicalize to demo-pkg and share one PyPI request - assert requested_urls == ["https://pypi.org/simple/demo-pkg"] + get.assert_called_once() + assert get.call_args.args == ("https://pypi.org/simple/demo-pkg",) assert pyproject["project"]["dependencies"] == ["Demo_Pkg>=2.0.0"] assert pyproject["dependency-groups"]["dev"] == ["demo-pkg>=2.0.0"] @@ -353,15 +291,12 @@ def test_excluded_pep_dependencies(patch_datetime_now, schedule, update_all): @pytest.mark.parametrize( - "location", + ("location", "table_name"), [ - (), - ("feature", "test"), - ("target", "linux-64"), - ("feature", "test", "target", "linux-64"), + ((), "dependencies"), + (("feature", "test", "target", "linux-64"), "pypi-dependencies"), ], ) -@pytest.mark.parametrize("table_name", ["dependencies", "pypi-dependencies"]) def test_excluded_pixi_dependencies(patch_datetime_now, schedule, location, table_name): deps = { "NumPy": ">= 1.10.0", @@ -385,62 +320,26 @@ def test_excluded_pixi_dependencies(patch_datetime_now, schedule, location, tabl @pytest.mark.parametrize("current", [None, ">= 3.9, < 4"]) def test_excluded_python(patch_datetime_now, schedule, current): - pyproject = _minimal_pyproject("numpy>=1.10.0") - if current is None: - del pyproject["project"]["requires-python"] - else: + pixi = {"pixi": {"dependencies": {"Python": ">= 3.9"}}} + pyproject = {"project": {"dependencies": ["numpy>=1.10.0"]}, "tool": deepcopy(pixi)} + if current: pyproject["project"]["requires-python"] = current - pyproject["tool"] = { - "pixi": { - "dependencies": {"Python": ">= 3.9"}, - "feature": { - "test": { - "target": { - "linux-64": { - "pypi-dependencies": { - "python": {"version": ">= 3.9", "extras": ["test"]} - } - } - } - } - }, - } - } - expected_tool = deepcopy(pyproject["tool"]) update_pyproject_toml(pyproject, schedule, excluded_packages=["PYTHON"]) - if current is None: - assert "requires-python" not in pyproject["project"] - else: - assert pyproject["project"]["requires-python"] == current - assert pyproject["tool"] == expected_tool + assert pyproject["project"].get("requires-python") == current assert pyproject["project"]["dependencies"] == ["numpy>=2.0.0"] + assert pyproject["tool"] == pixi -@pytest.mark.parametrize("filename", ["pyproject", "pyproject_pixi"]) -@pytest.mark.parametrize("exclude_all", [False, True]) -def test_empty_and_all_exclusions(patch_datetime_now, schedule, filename, exclude_all): - pyproject = read_toml(f"tests/test_data/{filename}.toml") - expected = deepcopy(pyproject) - if not exclude_all: - update_pyproject_toml(expected, schedule) - excluded_packages = ( - [pkg for entry in schedule for pkg in entry["packages"]] if exclude_all else () - ) - - with _mock_pypi() as mock_pypi: - update_pyproject_toml( - pyproject, schedule, 2.0, excluded_packages=excluded_packages - ) - - mock_pypi.assert_not_called() - assert dumps(pyproject) == dumps(expected) +def test_excluding_every_package_is_a_noop(patch_datetime_now, schedule): + pyproject = _minimal_pyproject("numpy>=1.10.0") + excluded = [pkg for entry in schedule for pkg in entry["packages"]] + update_pyproject_toml(pyproject, schedule, excluded_packages=excluded) + assert pyproject == _minimal_pyproject("numpy>=1.10.0") -@pytest.mark.parametrize( - "invalid", ["numpy>=1", "numpy[extra]", "numpy*", "*", "-numpy", "numpy pandas"] -) +@pytest.mark.parametrize("invalid", ["numpy>=1", "numpy[extra]"]) def test_invalid_exclusions_fail_before_mutation(patch_datetime_now, schedule, invalid): pyproject = _minimal_pyproject("numpy>=1.10.0") expected = deepcopy(pyproject) diff --git a/tests/test_versions.py b/tests/test_versions.py index 92f8c98..95558da 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -23,8 +23,6 @@ def test_repr_specset(): ("!=1.3.4.*,<2.0", "1.4.0", "!=1.3.4.*,<2.0,>=1.4.0"), # compatible-release specs keep their ceiling ("~=1.3", "1.4.0", "~=1.3,>=1.4.0"), - # ~= mixed with other restrictions, bound inside the compatible range - ("~=0.9,!=0.9.4.*,<2.0", "0.9.5", "~=0.9,!=0.9.4.*,<2.0,>=0.9.5"), # bound outside the compatible-release range ("~=0.9,!=1.3.4.*,<2.0", "1.4.0", None), # new bound conflicts with the upper bound