diff --git a/.github/workflows/test_action.yaml b/.github/workflows/test_action.yaml index 7960c8b..e539b83 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,15 @@ 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: | + 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/.gitignore b/.gitignore index 82c7512..611d20d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ schedule.json __pycache__ *.pyc *.lock +.DS_Store 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/action.yaml b/action.yaml index 3a2aedb..4c8d316 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 @@ -78,7 +83,7 @@ runs: if [ -n "$UPDATE_ALL" ]; then UPDATE_ALL_ARGS=(--update-all "$UPDATE_ALL") fi - pixi run --manifest-path "${GITHUB_ACTION_PATH}/pyproject.toml" update-dependencies "${GITHUB_WORKSPACE}/${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" --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 eddb2dd..2349689 100644 --- a/readme.md +++ b/readme.md @@ -50,11 +50,26 @@ 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 + +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: + update_all: 2 + excluded_packages: | + numpy, scikit-learn + python +``` + +Exclusions win over the schedule and `update_all`. The CLI takes the same value via `--excluded-packages`. + ## 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..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( @@ -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/spec0_action/__init__.py b/spec0_action/__init__.py index 6d8737a..9c2ff4b 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", []): @@ -83,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): @@ -91,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: @@ -117,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 new_version: return new_version[package_key] @@ -219,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 26a5558..6ab0976 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: @@ -53,19 +54,19 @@ 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) 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..457cfab 100644 --- a/spec0_versions.py +++ b/spec0_versions.py @@ -1,11 +1,10 @@ -import requests -import json import collections +import json from datetime import 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,14 +27,14 @@ "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").tz_localize(None) CURRENT_QUARTER_START = pd.Timestamp( CURRENT_DATE.year, (CURRENT_DATE.quarter - 1) * 3 + 1, 1 ) @@ -71,8 +70,8 @@ def get_release_dates(package, support_time=PLUS_24_MONTHS): 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] = { @@ -113,10 +112,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 @@ -208,7 +207,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 new file mode 100644 index 0000000..023f30f --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,57 @@ +import runpy +import sys +from pathlib import Path + +import pytest + +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", "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, 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__") + + 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"], + ) + + 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 515e249..8eb53bf 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" @@ -12,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.*")), ], ) @@ -31,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 new file mode 100644 index 0000000..38c7220 --- /dev/null +++ b/tests/test_spec0_versions.py @@ -0,0 +1,43 @@ +import json +import runpy +from pathlib import Path +from unittest.mock import Mock + +import pandas as pd +import requests + + +def test_generator_schedule(tmp_path, monkeypatch): + script = Path(__file__).resolve().parents[1] / "spec0_versions.py" + monkeypatch.chdir(tmp_path) + + 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 [ + ("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"), + ] + ] + response = Mock(json=Mock(return_value={"files": files})) + monkeypatch.setattr(requests, "get", Mock(return_value=response)) + + result = runpy.run_path(str(script)) + + schedule = { + entry["start_date"]: entry["packages"] + for entry in json.loads((tmp_path / "schedule.json").read_text()) + } + 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 fecc1e8..55316a0 100644 --- a/tests/test_update_pyproject_toml.py +++ b/tests/test_update_pyproject_toml.py @@ -1,12 +1,13 @@ import datetime -from unittest.mock import patch +from copy import deepcopy +from unittest.mock import Mock, call, patch import pytest from packaging.version import Version -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) @@ -47,65 +48,37 @@ def _mock_pypi(version=None): def _pypi_response(files): - class Response: - def raise_for_status(self): - pass - - 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 + return Mock(json=Mock(return_value={"files": files})) -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") - with _mock_pypi("2.28.0"): - update_pyproject_toml(pyproject, schedule, update_all=2.0) - # requests is not in SPEC 0 and is bumped from PyPI, numpy from the schedule +@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=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_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") - 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 ): @@ -127,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,59 +124,19 @@ 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"), ], ) 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", - ] - - -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"}, - ] + assert pyproject["project"] == {"requires-python": expected} def test_url_pinned_and_up_to_date_dependencies_left_untouched( @@ -272,29 +194,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( [ @@ -319,27 +218,142 @@ def test_update_all_uses_version_release_date_not_new_file_upload(patch_datetime ) -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", - } - ] - ) +@pytest.mark.parametrize( + ("stage", "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), + ), + ], +) +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 = get if stage == "get" else getattr(get.return_value, 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): + 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"] + + +@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", "table_name"), + [ + ((), "dependencies"), + (("feature", "test", "target", "linux-64"), "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): + pixi = {"pixi": {"dependencies": {"Python": ">= 3.9"}}} + pyproject = {"project": {"dependencies": ["numpy>=1.10.0"]}, "tool": deepcopy(pixi)} + if current: + pyproject["project"]["requires-python"] = current + + update_pyproject_toml(pyproject, schedule, excluded_packages=["PYTHON"]) + + assert pyproject["project"].get("requires-python") == current + assert pyproject["project"]["dependencies"] == ["numpy>=2.0.0"] + assert pyproject["tool"] == pixi + + +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]"]) +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"]) diff --git a/tests/test_versions.py b/tests/test_versions.py index 302dee6..95558da 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(): @@ -22,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