From fc62d3ee10c74ec7214fbe1255549ca91591aaf6 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Sun, 31 May 2026 09:35:44 -0700 Subject: [PATCH 1/8] Pass action inputs through env vars and shell arrays so paths with spaces stay intact and user-provided values are not evaluated by bash. --- action.yaml | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/action.yaml b/action.yaml index 0d9105b..6043bcb 100644 --- a/action.yaml +++ b/action.yaml @@ -58,26 +58,32 @@ runs: fileName: "schedule.json" - name: Run update script shell: bash + env: + PROJECT_FILE_NAME: ${{ inputs.project_file_name }} + SCHEDULE_INPUT: ${{ inputs.schedule_path }} + UPDATE_ALL: ${{ inputs.update_all }} run: | set -e - if [ -n "${{ inputs.schedule_path }}" ]; then - SCHEDULE_PATH="${{ github.workspace }}/${{ inputs.schedule_path }}" + if [ -n "$SCHEDULE_INPUT" ]; then + SCHEDULE_PATH="${GITHUB_WORKSPACE}/${SCHEDULE_INPUT}" else - SCHEDULE_PATH="${{ github.workspace }}/schedule.json" + SCHEDULE_PATH="${GITHUB_WORKSPACE}/schedule.json" fi - echo "Updating ${{ inputs.project_file_name }} using schedule $SCHEDULE_PATH" - UPDATE_ALL_FLAG="" - if [ -n "${{ inputs.update_all }}" ]; then - UPDATE_ALL_FLAG="--update-all ${{ inputs.update_all }}" + echo "Updating ${PROJECT_FILE_NAME} using schedule ${SCHEDULE_PATH}" + UPDATE_ALL_ARGS=() + if [ -n "$UPDATE_ALL" ]; then + UPDATE_ALL_ARGS=(--update-all "$UPDATE_ALL") fi - pixi run --manifest-path ${{ github.action_path }}/pyproject.toml update-dependencies "${{ github.workspace }}/${{ inputs.project_file_name }}" "$SCHEDULE_PATH" $UPDATE_ALL_FLAG + pixi run --manifest-path "${GITHUB_ACTION_PATH}/pyproject.toml" update-dependencies "${GITHUB_WORKSPACE}/${PROJECT_FILE_NAME}" "$SCHEDULE_PATH" "${UPDATE_ALL_ARGS[@]}" - name: Changes id: changes shell: bash + env: + PROJECT_FILE_NAME: ${{ inputs.project_file_name }} run: | echo "Showing changes that would be committed" - git --no-pager diff ${{ inputs.project_file_name }} - if git diff --quiet ${{ inputs.project_file_name }}; then + git --no-pager diff -- "$PROJECT_FILE_NAME" + if git diff --quiet -- "$PROJECT_FILE_NAME"; then echo "changes_detected=false" >> "$GITHUB_OUTPUT" else echo "changes_detected=true" >> "$GITHUB_OUTPUT" From aa86cd9b908776ac51cb5581b6ce12f6d1a2e862 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Sun, 31 May 2026 09:35:54 -0700 Subject: [PATCH 2/8] declare tomlkit dependency --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2f58399..e822daa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ name = "spec0-action" description = "Python code to update the lower bounds of Scientific Python libraries according to SPEC 0" requires-python = ">= 3.11" version = "1.0.0" -dependencies = ["packaging>=25.0", "pandas>=2.3.3", "requests>=2.32.5,<3"] +dependencies = ["packaging>=25.0", "pandas>=2.3.3", "requests>=2.32.5,<3", "tomlkit>=0.13.3,<0.14"] [build-system] build-backend = "hatchling.build" @@ -16,7 +16,6 @@ platforms = ["linux-64"] [tool.pixi.pypi-dependencies] spec0-action= { path = ".", editable = true } -tomlkit = ">=0.13.3,<0.14" [tool.pixi.tasks] update-dependencies = { cmd = ["python", "run_spec0_update.py"] } From 4d533fdbdeb7bea494801247ab822a0bef79d729 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Sun, 31 May 2026 09:36:00 -0700 Subject: [PATCH 3/8] Normalize package names for schedule matching, preserve existing Python specifiers, update optional and grouped dependencies, and skip non-version Pixi table dependencies. For update_all, select versions by their original release date instead of newer file upload dates. --- spec0_action/__init__.py | 122 +++++++++++++++++++++++++++++---------- 1 file changed, 91 insertions(+), 31 deletions(-) diff --git a/spec0_action/__init__.py b/spec0_action/__init__.py index 6a89f70..625aafa 100644 --- a/spec0_action/__init__.py +++ b/spec0_action/__init__.py @@ -1,4 +1,5 @@ import contextlib +from collections import defaultdict from packaging.specifiers import SpecifierSet from typing import Sequence, Dict import datetime @@ -15,7 +16,14 @@ read_toml, write_toml, ) -from packaging.version import Version, InvalidVersion +from packaging.version import Version +from packaging.utils import ( + InvalidSdistFilename, + InvalidWheelFilename, + canonicalize_name, + parse_sdist_filename, + parse_wheel_filename, +) __all__ = ["read_schedule", "read_toml", "write_toml", "update_pyproject_toml"] @@ -37,17 +45,12 @@ def _get_oldest_version_in_window(package: str, years: float) -> Version | None: data = resp.json() except Exception: return None - candidates: list[Version] = [] + release_dates: dict[Version, list[datetime.datetime]] = defaultdict(list) for f in data.get("files", []): - parts = f.get("filename", "").split("-") - if len(parts) < 2: - continue - try: - ver = Version(parts[1]) - except InvalidVersion: - continue - if ver.is_prerelease: + ver = _version_from_filename(f.get("filename", "")) + if ver is None or ver.is_prerelease: continue + upload_str = f.get("upload-time", "") upload_time = None for fmt in ["%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"]: @@ -56,21 +59,43 @@ def _get_oldest_version_in_window(package: str, years: float) -> Version | None: tzinfo=datetime.timezone.utc ) break - if upload_time is None or upload_time < cutoff: + if upload_time is None: continue - candidates.append(ver) + release_dates[ver].append(upload_time) + candidates = [ + ver + for ver, upload_times in release_dates.items() + if min(upload_times) >= cutoff + ] return min(candidates, default=None) -def update_pyproject_dependencies(dependencies: dict, schedule: Dict[str, str]): +def _version_from_filename(filename: str) -> Version | None: + try: + _, version, _, _ = parse_wheel_filename(filename) + return version + except InvalidWheelFilename: + pass + + try: + _, version = parse_sdist_filename(filename) + return version + except InvalidSdistFilename: + return None + + +def update_pyproject_dependencies(dependencies: list, schedule: Dict[str, str]): # Iterate by idx because we want to update it inplace for i in range(len(dependencies)): dep_str = dependencies[i] + if not isinstance(dep_str, str): + continue pkg, extras, spec, env = parse_pep_dependency(dep_str) - if isinstance(spec, Url) or pkg not in schedule: + schedule_key = canonicalize_name(pkg) + if isinstance(spec, Url) or schedule_key not in schedule: continue - new_lower_bound = Version(schedule[pkg]) + new_lower_bound = Version(schedule[schedule_key]) try: spec = tighten_lower_bound(spec or SpecifierSet(), new_lower_bound) # Will raise a value error if bound is already tighter, in this case we just do nothing and continue @@ -85,14 +110,15 @@ def update_pyproject_dependencies(dependencies: dict, schedule: Dict[str, str]): def update_dependency_table(dep_table: dict, new_versions: dict): for pkg, pkg_data in dep_table.items(): + schedule_key = canonicalize_name(pkg) # Don't do anything for pkgs that aren't in our schedule - if pkg not in new_versions: + if schedule_key not in new_versions: continue # Like pkg = ">x.y.z, Date: Sun, 31 May 2026 09:36:08 -0700 Subject: [PATCH 4/8] Cover dependency edge cases --- tests/test_update_pyproject_toml.py | 119 ++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/tests/test_update_pyproject_toml.py b/tests/test_update_pyproject_toml.py index dd69c42..95ee3d6 100644 --- a/tests/test_update_pyproject_toml.py +++ b/tests/test_update_pyproject_toml.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +from packaging.specifiers import SpecifierSet from packaging.version import Version from spec0_action.parsing import read_schedule, read_toml @@ -89,3 +90,121 @@ def test_update_all_noop_when_not_set(patch_datetime_now): with patch.object(spec0_action, "_get_oldest_version_in_window") as mock_pypi: update_pyproject_toml(pyproject, schedule) mock_pypi.assert_not_called() + + +def test_requires_python_preserves_existing_restrictions(patch_datetime_now): + pyproject = _minimal_pyproject() + pyproject["project"]["requires-python"] = ">=3.9,<3.14,!=3.13.*" + schedule = read_schedule("tests/test_data/test_schedule.json") + + update_pyproject_toml(pyproject, schedule) + + assert SpecifierSet(pyproject["project"]["requires-python"]) == SpecifierSet( + ">=3.12,<3.14,!=3.13.*" + ) + + +def test_canonical_package_names_match_schedule(patch_datetime_now): + pyproject = _minimal_pyproject("Numpy>=1.20", "scikit_learn>=1.0") + schedule = read_schedule("tests/test_data/test_schedule.json") + + 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): + pyproject = _minimal_pyproject() + pyproject["project"]["optional-dependencies"] = { + "test": ["Numpy>=1.20"], + } + pyproject["dependency-groups"] = { + "dev": ["numpy>=1.20", {"include-group": "test"}], + } + schedule = read_schedule("tests/test_data/test_schedule.json") + + 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_missing_project_dependencies_is_noop(patch_datetime_now): + pyproject = {"project": {"requires-python": ">=3.9"}} + schedule = read_schedule("tests/test_data/test_schedule.json") + + update_pyproject_toml(pyproject, schedule) + + assert SpecifierSet(pyproject["project"]["requires-python"]) == SpecifierSet( + ">=3.12" + ) + + +def test_pixi_feature_pypi_dependencies_and_non_version_tables(patch_datetime_now): + pyproject = _minimal_pyproject() + pyproject["tool"] = { + "pixi": { + "dependencies": { + "scikit-learn": {"git": "https://example.invalid/scikit-learn.git"} + }, + "feature": { + "test": { + "pypi-dependencies": {"Numpy": ">=1.20"}, + "dependencies": { + "xarray": {"url": "https://example.invalid/pkg.whl"} + }, + } + }, + } + } + schedule = read_schedule("tests/test_data/test_schedule.json") + + update_pyproject_toml(pyproject, schedule) + + assert ( + pyproject["tool"]["pixi"]["feature"]["test"]["pypi-dependencies"]["Numpy"] + == ">=2.0.0" + ) + assert pyproject["tool"]["pixi"]["dependencies"]["scikit-learn"] == { + "git": "https://example.invalid/scikit-learn.git" + } + assert pyproject["tool"]["pixi"]["feature"]["test"]["dependencies"]["xarray"] == { + "url": "https://example.invalid/pkg.whl" + } + + +def test_update_all_uses_version_release_date_not_new_file_upload( + patch_datetime_now, +): + class Response: + def raise_for_status(self): + pass + + def json(self): + return { + "files": [ + { + "filename": "example-1.0.0.tar.gz", + "upload-time": "2020-01-01T00:00:00Z", + }, + { + "filename": "example-1.0.0-py3-none-any.whl", + "upload-time": "2025-01-01T00:00:00Z", + }, + { + "filename": "example-2.0.0-py3-none-any.whl", + "upload-time": "2024-01-01T00:00:00Z", + }, + ] + } + + with patch.object(spec0_action.requests, "get", return_value=Response()): + assert spec0_action._get_oldest_version_in_window("example", 2) == Version( + "2.0.0" + ) From 54e31826ce47f7e35ca0370e42024eb7431be7b1 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Thu, 4 Jun 2026 09:14:33 -0700 Subject: [PATCH 5/8] Fix issues around lower bounds --- spec0_action/__init__.py | 77 +++++++++++---------- spec0_action/versions.py | 3 + tests/test_data/pyproject_pixi_updated.toml | 2 +- tests/test_update_pyproject_toml.py | 36 ++++++++++ tests/test_versions.py | 21 ++++-- 5 files changed, 97 insertions(+), 42 deletions(-) diff --git a/spec0_action/__init__.py b/spec0_action/__init__.py index 625aafa..5e3c192 100644 --- a/spec0_action/__init__.py +++ b/spec0_action/__init__.py @@ -108,6 +108,24 @@ def update_pyproject_dependencies(dependencies: list, schedule: Dict[str, str]): dependencies[i] = new_dep_str +def iter_pep_dependency_lists(pyproject_data: dict, project_data: dict): + dependencies = project_data.get("dependencies") + if isinstance(dependencies, list): + yield dependencies + + optional_dependencies = project_data.get("optional-dependencies", {}) + if isinstance(optional_dependencies, dict): + for dependencies in optional_dependencies.values(): + if isinstance(dependencies, list): + yield dependencies + + dependency_groups = pyproject_data.get("dependency-groups", {}) + if isinstance(dependency_groups, dict): + for dependencies in dependency_groups.values(): + if isinstance(dependencies, list): + yield dependencies + + def update_dependency_table(dep_table: dict, new_versions: dict): for pkg, pkg_data in dep_table.items(): schedule_key = canonicalize_name(pkg) @@ -119,7 +137,10 @@ def update_dependency_table(dep_table: dict, new_versions: dict): if not is_url_spec(pkg_data): spec = parse_version_spec(pkg_data) new_lower_bound = Version(new_versions[schedule_key]) - spec = tighten_lower_bound(spec, new_lower_bound) + try: + spec = tighten_lower_bound(spec, new_lower_bound) + except ValueError: + continue dep_table[pkg] = repr_spec_set(spec) else: # We don't do anything with url spec dependencies @@ -131,7 +152,10 @@ def update_dependency_table(dep_table: dict, new_versions: dict): continue spec = parse_version_spec(pkg_data["version"]) new_lower_bound = Version(new_versions[schedule_key]) - spec = tighten_lower_bound(spec, new_lower_bound) + try: + spec = tighten_lower_bound(spec, new_lower_bound) + except ValueError: + continue pkg_data["version"] = repr_spec_set(spec) @@ -188,42 +212,25 @@ def update_pyproject_toml( python_spec = parse_version_spec(new_version["python"]) project_data["requires-python"] = repr_spec_set(python_spec) - dependencies = project_data.get("dependencies") - if isinstance(dependencies, list): + for dependencies in iter_pep_dependency_lists(pyproject_data, project_data): update_pyproject_dependencies(dependencies, new_version) - optional_dependencies = project_data.get("optional-dependencies", {}) - if isinstance(optional_dependencies, dict): - for dependencies in optional_dependencies.values(): - if isinstance(dependencies, list): - update_pyproject_dependencies(dependencies, new_version) - - dependency_groups = pyproject_data.get("dependency-groups", {}) - if isinstance(dependency_groups, dict): - for dependencies in dependency_groups.values(): - if isinstance(dependencies, list): - update_pyproject_dependencies(dependencies, new_version) - if "tool" in pyproject_data and "pixi" in pyproject_data["tool"]: pixi_data = pyproject_data["tool"]["pixi"] update_pixi_dependencies(pixi_data, new_version) if update_all is not None: - deps = project_data.get("dependencies", []) - for i, dep_str in enumerate(deps): - if not isinstance(dep_str, str): - continue - pkg, extras, spec, env = parse_pep_dependency(dep_str) - if ( - canonicalize_name(pkg) in new_version - or isinstance(spec, Url) - or spec is None - ): - continue - min_ver = _get_oldest_version_in_window(pkg, update_all) - if min_ver is None: - continue - try: - updated = tighten_lower_bound(spec, min_ver) - deps[i] = f"{pkg}{extras or ''}{repr_spec_set(updated)}{env or ''}" - except ValueError: - continue + for deps in iter_pep_dependency_lists(pyproject_data, project_data): + for i, dep_str in enumerate(deps): + if not isinstance(dep_str, str): + continue + pkg, extras, spec, env = parse_pep_dependency(dep_str) + if canonicalize_name(pkg) in new_version or isinstance(spec, Url): + continue + min_ver = _get_oldest_version_in_window(pkg, update_all) + if min_ver is None: + continue + try: + updated = tighten_lower_bound(spec or SpecifierSet(), min_ver) + deps[i] = f"{pkg}{extras or ''}{repr_spec_set(updated)}{env or ''}" + except ValueError: + continue diff --git a/spec0_action/versions.py b/spec0_action/versions.py index f88748e..3ced87b 100644 --- a/spec0_action/versions.py +++ b/spec0_action/versions.py @@ -5,6 +5,9 @@ def tighten_lower_bound( spec_set: SpecifierSet, new_lower_bound: Version ) -> SpecifierSet: + if new_lower_bound not in spec_set: + raise ValueError(f"{new_lower_bound} does not satisfy {spec_set}") + out = [] contains_lower_bound = False diff --git a/tests/test_data/pyproject_pixi_updated.toml b/tests/test_data/pyproject_pixi_updated.toml index 69cfc85..7d62ffa 100644 --- a/tests/test_data/pyproject_pixi_updated.toml +++ b/tests/test_data/pyproject_pixi_updated.toml @@ -31,4 +31,4 @@ xarray = ">=2024.1.0" bar = ["foo"] [tool.pixi.dependencies] -numpy = ">=2.0.0,<2" +numpy = ">=1.10.0,<2" diff --git a/tests/test_update_pyproject_toml.py b/tests/test_update_pyproject_toml.py index 95ee3d6..4c00cb9 100644 --- a/tests/test_update_pyproject_toml.py +++ b/tests/test_update_pyproject_toml.py @@ -92,6 +92,30 @@ def test_update_all_noop_when_not_set(patch_datetime_now): mock_pypi.assert_not_called() +def test_update_all_updates_optional_dependency_groups_and_unbounded( + patch_datetime_now, +): + pyproject = _minimal_pyproject("requests") + pyproject["project"]["optional-dependencies"] = { + "test": ["idna>=3.0.0"], + } + pyproject["dependency-groups"] = { + "dev": ["charset-normalizer>=3.0.0", {"include-group": "test"}], + } + schedule = read_schedule("tests/test_data/test_schedule.json") + with patch.object( + spec0_action, "_get_oldest_version_in_window", return_value=Version("9.0.0") + ): + update_pyproject_toml(pyproject, schedule, update_all=2.0) + + assert pyproject["project"]["dependencies"] == ["requests>=9.0.0"] + assert pyproject["project"]["optional-dependencies"]["test"] == ["idna>=9.0.0"] + assert pyproject["dependency-groups"]["dev"] == [ + "charset-normalizer>=9.0.0", + {"include-group": "test"}, + ] + + def test_requires_python_preserves_existing_restrictions(patch_datetime_now): pyproject = _minimal_pyproject() pyproject["project"]["requires-python"] = ">=3.9,<3.14,!=3.13.*" @@ -104,6 +128,18 @@ def test_requires_python_preserves_existing_restrictions(patch_datetime_now): ) +def test_requires_python_keeps_incompatible_existing_restrictions(patch_datetime_now): + pyproject = _minimal_pyproject() + pyproject["project"]["requires-python"] = ">=3.9,<3.12" + schedule = read_schedule("tests/test_data/test_schedule.json") + + update_pyproject_toml(pyproject, schedule) + + assert SpecifierSet(pyproject["project"]["requires-python"]) == SpecifierSet( + ">=3.9,<3.12" + ) + + def test_canonical_package_names_match_schedule(patch_datetime_now): pyproject = _minimal_pyproject("Numpy>=1.20", "scikit_learn>=1.0") schedule = read_schedule("tests/test_data/test_schedule.json") diff --git a/tests/test_versions.py b/tests/test_versions.py index e364f7b..80ac2f8 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -1,6 +1,7 @@ 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(): @@ -16,14 +17,22 @@ def test_tighter_lower_bound_any(): def test_tighter_lower_bound_leaves_other_restrictions(): - spec = SpecifierSet("~= 0.9,>=1.0,!= 1.3.4.*,< 2.0") - lower_bound = Version("3.8.0") + spec = SpecifierSet(">=1.0,!= 1.3.4.*,< 2.0") + lower_bound = Version("1.4.0") tightened = tighten_lower_bound(spec, lower_bound) - assert tightened == SpecifierSet("~= 0.9,>=3.8.0,!=1.3.4.*,<2.0") + assert tightened == SpecifierSet(">=1.4.0,!=1.3.4.*,<2.0") def test_tighter_lower_bound_adds_lower_bound_if_not_present(): - spec = SpecifierSet("~=0.9,!=1.3.4.*,<2.0") - lower_bound = Version("3.8.0") + spec = SpecifierSet("!=1.3.4.*,<2.0") + lower_bound = Version("1.4.0") tightened = tighten_lower_bound(spec, lower_bound) - assert tightened == SpecifierSet("~= 0.9, != 1.3.4.*, < 2.0, >=3.8.0") + assert tightened == SpecifierSet("!=1.3.4.*,<2.0,>=1.4.0") + + +def test_tighter_lower_bound_rejects_incompatible_restrictions(): + spec = SpecifierSet(">=1.0,<2.0") + lower_bound = Version("2.0.0") + + with pytest.raises(ValueError): + tighten_lower_bound(spec, lower_bound) From b40206779b5a954e2d4d80fb3f806826b7d4419f Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Mon, 8 Jun 2026 09:40:10 -0700 Subject: [PATCH 6/8] Update readme.md --- readme.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index eb3720f..9807f7e 100644 --- a/readme.md +++ b/readme.md @@ -22,11 +22,17 @@ permissions: contents: write pull-requests: write +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: true + jobs: update: runs-on: ubuntu-latest steps: - - uses: scientific-python/spec0-action@v1 + - uses: scientific-python/spec0-action@8b8b76f254aecce36e6f07de7dde174cb3cafa81 # v1.3 + with: + update_all: 2 # also bump non-SPEC0 deps older than 2 years ``` No PAT required. From 9d2dd027572443453663e5ce5820e92e94d8f8ab Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Thu, 11 Jun 2026 15:28:23 -0700 Subject: [PATCH 7/8] Stop updating own package --- spec0_action/__init__.py | 51 +++++++++++++++++++++-------- tests/test_update_pyproject_toml.py | 28 ++++++++++++++++ 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/spec0_action/__init__.py b/spec0_action/__init__.py index 5e3c192..62ce8c4 100644 --- a/spec0_action/__init__.py +++ b/spec0_action/__init__.py @@ -85,7 +85,9 @@ def _version_from_filename(filename: str) -> Version | None: return None -def update_pyproject_dependencies(dependencies: list, schedule: Dict[str, str]): +def update_pyproject_dependencies( + dependencies: list, schedule: Dict[str, str], own_name: str | None = None +): # Iterate by idx because we want to update it inplace for i in range(len(dependencies)): dep_str = dependencies[i] @@ -93,7 +95,11 @@ def update_pyproject_dependencies(dependencies: list, schedule: Dict[str, str]): continue pkg, extras, spec, env = parse_pep_dependency(dep_str) schedule_key = canonicalize_name(pkg) - if isinstance(spec, Url) or schedule_key not in schedule: + if ( + isinstance(spec, Url) + or schedule_key == own_name + or schedule_key not in schedule + ): continue new_lower_bound = Version(schedule[schedule_key]) try: @@ -126,11 +132,13 @@ def iter_pep_dependency_lists(pyproject_data: dict, project_data: dict): yield dependencies -def update_dependency_table(dep_table: dict, new_versions: dict): +def update_dependency_table( + dep_table: dict, new_versions: dict, own_name: str | None = None +): for pkg, pkg_data in dep_table.items(): schedule_key = canonicalize_name(pkg) - # Don't do anything for pkgs that aren't in our schedule - if schedule_key not in new_versions: + # Don't do anything for the package itself or pkgs that aren't in our schedule + if schedule_key == own_name or schedule_key not in new_versions: continue # Like pkg = ">x.y.z,=2.0.0") + pyproject["project"]["name"] = "My_Package" + pyproject["dependency-groups"] = { + "tests": ["my-package[plotting,tests-only]"], + } + schedule = read_schedule("tests/test_data/test_schedule.json") + with patch.object( + spec0_action, "_get_oldest_version_in_window", return_value=Version("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): + # A project named like a schedule package must not have its self-reference pinned + pyproject = _minimal_pyproject("numpy[test]") + pyproject["project"]["name"] = "numpy" + schedule = read_schedule("tests/test_data/test_schedule.json") + + update_pyproject_toml(pyproject, schedule) + + assert pyproject["project"]["dependencies"] == ["numpy[test]"] + + def test_requires_python_preserves_existing_restrictions(patch_datetime_now): pyproject = _minimal_pyproject() pyproject["project"]["requires-python"] = ">=3.9,<3.14,!=3.13.*" From b42a28a5c0230ff652c75ab3e0f3ab158841f093 Mon Sep 17 00:00:00 2001 From: Nabil Freij Date: Fri, 12 Jun 2026 10:18:50 -0700 Subject: [PATCH 8/8] Have you ever wondered where your life went wrong? --- readme.md | 4 +- spec0_action/__init__.py | 238 ++++++++++------------- spec0_action/parsing.py | 3 +- spec0_action/versions.py | 19 +- tests/test_parsing.py | 117 +++++------- tests/test_update_pyproject_toml.py | 287 +++++++++++++++++----------- tests/test_versions.py | 55 +++--- 7 files changed, 374 insertions(+), 349 deletions(-) diff --git a/readme.md b/readme.md index 9807f7e..4bd0526 100644 --- a/readme.md +++ b/readme.md @@ -30,9 +30,9 @@ jobs: update: runs-on: ubuntu-latest steps: - - uses: scientific-python/spec0-action@8b8b76f254aecce36e6f07de7dde174cb3cafa81 # v1.3 + - uses: scientific-python/spec0-action@8b8b76f254aecce36e6f07de7dde174cb3cafa81 # v1.3 with: - update_all: 2 # also bump non-SPEC0 deps older than 2 years + update_all: 2 # also bump non-SPEC0 deps older than 2 years ``` No PAT required. diff --git a/spec0_action/__init__.py b/spec0_action/__init__.py index 62ce8c4..6d8737a 100644 --- a/spec0_action/__init__.py +++ b/spec0_action/__init__.py @@ -1,7 +1,6 @@ -import contextlib -from collections import defaultdict +from functools import cache from packaging.specifiers import SpecifierSet -from typing import Sequence, Dict +from typing import Callable, Sequence, Dict import datetime import requests @@ -28,6 +27,7 @@ __all__ = ["read_schedule", "read_toml", "write_toml", "update_pyproject_toml"] +@cache def _get_oldest_version_in_window(package: str, years: float) -> Version | None: """ Query PyPI, return oldest non-pre release version uploaded within the last ``years`` years. @@ -45,28 +45,23 @@ def _get_oldest_version_in_window(package: str, years: float) -> Version | None: data = resp.json() except Exception: return None - release_dates: dict[Version, list[datetime.datetime]] = defaultdict(list) + first_uploads: dict[Version, datetime.datetime] = {} for f in data.get("files", []): ver = _version_from_filename(f.get("filename", "")) if ver is None or ver.is_prerelease: continue - upload_str = f.get("upload-time", "") - upload_time = None - for fmt in ["%Y-%m-%dT%H:%M:%S.%fZ", "%Y-%m-%dT%H:%M:%SZ"]: - with contextlib.suppress(ValueError): - upload_time = datetime.datetime.strptime(upload_str, fmt).replace( - tzinfo=datetime.timezone.utc - ) - break - if upload_time is None: + try: + upload_time = datetime.datetime.fromisoformat(f.get("upload-time", "")) + except ValueError: continue - release_dates[ver].append(upload_time) + + previous = first_uploads.get(ver) + if previous is None or upload_time < previous: + first_uploads[ver] = upload_time candidates = [ - ver - for ver, upload_times in release_dates.items() - if min(upload_times) >= cutoff + ver for ver, first_upload in first_uploads.items() if first_upload >= cutoff ] return min(candidates, default=None) @@ -86,105 +81,101 @@ def _version_from_filename(filename: str) -> Version | None: def update_pyproject_dependencies( - dependencies: list, schedule: Dict[str, str], own_name: str | None = None + dependencies: list, + resolve_lower_bound: Callable[[str], Version | None], + own_name: str | None, ): - # Iterate by idx because we want to update it inplace - for i in range(len(dependencies)): - dep_str = dependencies[i] + # Assign by index so the (tomlkit) list is updated in place + for i, dep_str in enumerate(dependencies): if not isinstance(dep_str, str): continue pkg, extras, spec, env = parse_pep_dependency(dep_str) - schedule_key = canonicalize_name(pkg) - if ( - isinstance(spec, Url) - or schedule_key == own_name - or schedule_key not in schedule - ): + package_key = canonicalize_name(pkg) + if isinstance(spec, Url) or package_key == own_name: continue - new_lower_bound = Version(schedule[schedule_key]) - try: - spec = tighten_lower_bound(spec or SpecifierSet(), new_lower_bound) - # Will raise a value error if bound is already tighter, in this case we just do nothing and continue - except ValueError: + new_lower_bound = resolve_lower_bound(package_key) + if new_lower_bound is None: continue - if not extras: - new_dep_str = f"{pkg}{repr_spec_set(spec)}{env or ''}" - else: - new_dep_str = f"{pkg}{extras}{repr_spec_set(spec)}{env or ''}" - dependencies[i] = new_dep_str - - -def iter_pep_dependency_lists(pyproject_data: dict, project_data: dict): - dependencies = project_data.get("dependencies") - if isinstance(dependencies, list): - yield dependencies + new_spec = tighten_lower_bound(spec or SpecifierSet(), new_lower_bound) + if new_spec is None or new_spec == spec: + # Skip no-op updates so unchanged specs keep their original formatting + continue + dependencies[i] = f"{pkg}{extras or ''}{repr_spec_set(new_spec)}{env or ''}" - optional_dependencies = project_data.get("optional-dependencies", {}) - if isinstance(optional_dependencies, dict): - for dependencies in optional_dependencies.values(): - if isinstance(dependencies, list): - yield dependencies - dependency_groups = pyproject_data.get("dependency-groups", {}) - if isinstance(dependency_groups, dict): - for dependencies in dependency_groups.values(): - if isinstance(dependencies, list): - yield dependencies +def iter_pep_dependency_lists(pyproject_data: dict): + project_data = pyproject_data.get("project") + project_data = project_data if isinstance(project_data, dict) else {} + groups = [project_data.get("dependencies")] + for table in ( + project_data.get("optional-dependencies"), + pyproject_data.get("dependency-groups"), + ): + if isinstance(table, dict): + groups.extend(table.values()) + yield from (group for group in groups if isinstance(group, list)) def update_dependency_table( - dep_table: dict, new_versions: dict, own_name: str | None = None + dep_table: dict, new_versions: Dict[str, Version], own_name: str | None ): for pkg, pkg_data in dep_table.items(): - schedule_key = canonicalize_name(pkg) - # Don't do anything for the package itself or pkgs that aren't in our schedule - if schedule_key == own_name or schedule_key not in new_versions: + package_key = canonicalize_name(pkg) + if package_key == own_name or package_key not in new_versions: continue # Like pkg = ">x.y.z,x.y.z", ...} + spec_str = pkg_data["version"] else: - # Table like in tests = {path = "."} - if not isinstance(pkg_data, dict) or "version" not in pkg_data: - # We don't do anything with path, url, git, or other non-version dependencies - continue - spec = parse_version_spec(pkg_data["version"]) - new_lower_bound = Version(new_versions[schedule_key]) - try: - spec = tighten_lower_bound(spec, new_lower_bound) - except ValueError: - continue - pkg_data["version"] = repr_spec_set(spec) + # We don't do anything with path, url, git, or other non-version dependencies + continue + current_spec = parse_version_spec(spec_str) + new_spec = tighten_lower_bound(current_spec, new_versions[package_key]) + if new_spec is None or new_spec == current_spec: + continue + if isinstance(pkg_data, str): + dep_table[pkg] = repr_spec_set(new_spec) + else: + pkg_data["version"] = repr_spec_set(new_spec) def update_pixi_dependencies( - pixi_tables: dict, schedule: Dict[str, str], own_name: str | None = None + pixi_tables: dict, new_versions: Dict[str, Version], own_name: str | None ): - if "pypi-dependencies" in pixi_tables: - update_dependency_table(pixi_tables["pypi-dependencies"], schedule, own_name) - if "dependencies" in pixi_tables: - update_dependency_table(pixi_tables["dependencies"], schedule, own_name) - - if "feature" in pixi_tables: - for _, feature_data in pixi_tables["feature"].items(): - if "dependencies" in feature_data: - update_dependency_table( - feature_data["dependencies"], schedule, own_name - ) - if "pypi-dependencies" in feature_data: - update_dependency_table( - feature_data["pypi-dependencies"], schedule, own_name - ) + for key in ("dependencies", "pypi-dependencies"): + dep_table = pixi_tables.get(key) + if isinstance(dep_table, dict): + update_dependency_table(dep_table, new_versions, own_name) + + # Recurse into [tool.pixi.feature.X] and platform tables like + # [tool.pixi.target.linux-64], which hold the same dependency keys + for key in ("feature", "target"): + subtables = pixi_tables.get(key) + if isinstance(subtables, dict): + for subtable in subtables.values(): + if isinstance(subtable, dict): + update_pixi_dependencies(subtable, new_versions, own_name) + + +def _update_requires_python(project_data: dict, new_lower_bound: Version): + current_requires_python = project_data.get("requires-python") + if not current_requires_python: + project_data["requires-python"] = f">={new_lower_bound}" + return + try: + current_spec = parse_version_spec(current_requires_python) + except ValueError: + # Leave specs we can't parse (e.g. poetry-style "^3.10") alone + return + new_spec = tighten_lower_bound(current_spec, new_lower_bound) + if new_spec is not None and new_spec != current_spec: + # Only write when the bound actually moved, to avoid cosmetic rewrites + project_data["requires-python"] = repr_spec_set(new_spec) def update_pyproject_toml( @@ -200,11 +191,11 @@ def update_pyproject_toml( ), key=lambda s: datetime.datetime.fromisoformat(s["start_date"]), ) - new_version = {} + new_version: Dict[str, Version] = {} for schedule in applicable: # Fill in the latest known requirement (schedule is sorted, newer entries overwrite older) for pkg, version in schedule["packages"].items(): - new_version[canonicalize_name(pkg)] = version + new_version[canonicalize_name(pkg)] = Version(version) if not new_version: raise RuntimeError( "Could not find schedule that applies to current time, perhaps your schedule is outdated." @@ -215,47 +206,20 @@ def update_pyproject_toml( # Self-references like "pkg[extras]" are used to share extras between # dependency groups, their version is always the local one so never pin it. own_name = project_data.get("name") - if isinstance(own_name, str): - own_name = canonicalize_name(own_name) - else: - own_name = None - if "python" in new_version and isinstance(project_data, dict): - current_requires_python = project_data.get("requires-python") - if current_requires_python: - try: - python_spec = tighten_lower_bound( - parse_version_spec(current_requires_python), - Version(new_version["python"]), - ) - except ValueError: - python_spec = parse_version_spec(current_requires_python) - else: - python_spec = parse_version_spec(new_version["python"]) - project_data["requires-python"] = repr_spec_set(python_spec) + own_name = canonicalize_name(own_name) if isinstance(own_name, str) else None + + if "python" in new_version: + _update_requires_python(project_data, new_version["python"]) + + def resolve_lower_bound(package_key: str) -> Version | None: + if package_key in new_version: + return new_version[package_key] + if update_all is not None: + return _get_oldest_version_in_window(package_key, update_all) + return None - for dependencies in iter_pep_dependency_lists(pyproject_data, project_data): - update_pyproject_dependencies(dependencies, new_version, own_name) + for dependencies in iter_pep_dependency_lists(pyproject_data): + update_pyproject_dependencies(dependencies, resolve_lower_bound, own_name) if "tool" in pyproject_data and "pixi" in pyproject_data["tool"]: - pixi_data = pyproject_data["tool"]["pixi"] - update_pixi_dependencies(pixi_data, new_version, own_name) - if update_all is not None: - for deps in iter_pep_dependency_lists(pyproject_data, project_data): - for i, dep_str in enumerate(deps): - if not isinstance(dep_str, str): - continue - pkg, extras, spec, env = parse_pep_dependency(dep_str) - if ( - canonicalize_name(pkg) in new_version - or canonicalize_name(pkg) == own_name - or isinstance(spec, Url) - ): - continue - min_ver = _get_oldest_version_in_window(pkg, update_all) - if min_ver is None: - continue - try: - updated = tighten_lower_bound(spec or SpecifierSet(), min_ver) - deps[i] = f"{pkg}{extras or ''}{repr_spec_set(updated)}{env or ''}" - except ValueError: - continue + update_pixi_dependencies(pyproject_data["tool"]["pixi"], new_version, own_name) diff --git a/spec0_action/parsing.py b/spec0_action/parsing.py index c4bbf57..26a5558 100644 --- a/spec0_action/parsing.py +++ b/spec0_action/parsing.py @@ -28,7 +28,6 @@ def parse_version_spec(s: str) -> SpecifierSet: # see https://packaging.python.org/en/latest/specifications/version-specifiers/ return SpecifierSet(">=0") try: - # If we can simply parse it return it return SpecifierSet(s) except InvalidSpecifier: try: @@ -39,7 +38,7 @@ def parse_version_spec(s: str) -> SpecifierSet: try: return SpecifierSet(f"=={s}") except InvalidVersion: - # if we don't return later raise is the same + # fall through to the raise below pass raise ValueError(f"{s} is not a version or specifyer") diff --git a/spec0_action/versions.py b/spec0_action/versions.py index 3ced87b..792dc7b 100644 --- a/spec0_action/versions.py +++ b/spec0_action/versions.py @@ -4,22 +4,27 @@ def tighten_lower_bound( spec_set: SpecifierSet, new_lower_bound: Version -) -> SpecifierSet: +) -> SpecifierSet | None: + """ + Return ``spec_set`` with its lower bound raised to ``new_lower_bound``. + + Returns None when the new bound does not satisfy ``spec_set`` (the existing + bounds are already tighter or conflict with it). + """ if new_lower_bound not in spec_set: - raise ValueError(f"{new_lower_bound} does not satisfy {spec_set}") + return None out = [] contains_lower_bound = False for spec in spec_set: - if spec.operator not in [">", ">="]: - out.append(spec) - continue - if new_lower_bound in spec: + if spec.operator in (">", ">="): + # new_lower_bound satisfies every specifier in the set, so it can + # simply replace any existing lower bound out.append(Specifier(f">={new_lower_bound}")) contains_lower_bound = True else: - raise ValueError(f"{spec} is already stricter than {new_lower_bound}") + out.append(spec) if not contains_lower_bound: out.append(Specifier(f">={new_lower_bound}")) diff --git a/tests/test_parsing.py b/tests/test_parsing.py index f08c6dc..515e249 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -3,73 +3,58 @@ import pytest from urllib.parse import urlparse +URL = "https://github.com/pypa/pip/archive/1.3.1.zip#sha1=da9234ee9982d4bbb3c72346a6de940a148ea686" -def test_parsing_correct(): - assert SpecifierSet(">=0") == parse_version_spec("*") - assert SpecifierSet(">4,<9") == parse_version_spec(">4, <9") - assert SpecifierSet(">=4") == parse_version_spec(">=4") - assert SpecifierSet(">=2025.7") == parse_version_spec(">=2025.7") - assert SpecifierSet("==3.11.*") == parse_version_spec("3.11.*") - -def test_parsing_incorrect(): - with pytest.raises(ValueError): - parse_version_spec("-18") - with pytest.raises(ValueError): - parse_version_spec("asdf") - - -def test_pep_dependency_parsing(): - matplotlib_str = "matplotlib" - pkg, features, spec, env = parse_pep_dependency(matplotlib_str) - assert pkg == "matplotlib", pkg - assert features is None, features - assert spec is None, spec - assert env is None, env - - -def test_pep_dependency_parsing_with_spec_and_optional_dep(): - matplotlib_str = "matplotlib[foo,bar]>=3.7.0,<4" - pkg, features, spec, env = parse_pep_dependency(matplotlib_str) - assert pkg == "matplotlib", pkg - assert features == "[foo,bar]", features - assert spec == SpecifierSet(">=3.7.0,<4"), spec - assert env is None, env +@pytest.mark.parametrize( + ("spec_str", "expected"), + [ + ("*", SpecifierSet(">=0")), + (">4, <9", SpecifierSet(">4,<9")), + (">=4", SpecifierSet(">=4")), + (">=2025.7", SpecifierSet(">=2025.7")), + ("3.11.*", SpecifierSet("==3.11.*")), + ], +) +def test_parse_version_spec(spec_str, expected): + assert parse_version_spec(spec_str) == expected -def test_pep_dependency_parsing_with_spec(): - matplotlib_str = "matplotlib>=3.7.0,<4" - pkg, features, spec, env = parse_pep_dependency(matplotlib_str) - assert pkg == "matplotlib", pkg - assert features is None, features - assert spec == SpecifierSet(">=3.7.0,<4"), spec - assert env is None, env - - -def test_pep_dependency_parsing_with_url_spec(): - dep_str = "matplotlib @ https://github.com/pypa/pip/archive/1.3.1.zip#sha1=da9234ee9982d4bbb3c72346a6de940a148ea686" - pkg, features, spec, env = parse_pep_dependency(dep_str) - assert pkg == "matplotlib", pkg - assert features is None, features - assert spec == urlparse( - " https://github.com/pypa/pip/archive/1.3.1.zip#sha1=da9234ee9982d4bbb3c72346a6de940a148ea686" - ), spec - assert env is None, env - - -def test_pep_dependency_parsing_extra_restrictions(): - matplotlib_str = "matplotlib>=3.7.0,<4,!=3.8.14" - pkg, features, spec, env = parse_pep_dependency(matplotlib_str) - assert pkg == "matplotlib", pkg - assert features is None, features - assert spec == SpecifierSet("!=3.8.14,<4,>=3.7.0"), spec - assert env is None, env - - -def test_pep_dependency_parsing_with_environment_marker(): - matplotlib_str = "matplotlib>=3.7.0,<4;sys_platform != 'win32'" - pkg, features, spec, env = parse_pep_dependency(matplotlib_str) - assert pkg == "matplotlib", pkg - assert features is None, features - assert spec == SpecifierSet(">=3.7.0,<4"), spec - assert env == ";sys_platform != 'win32'", env +@pytest.mark.parametrize("spec_str", ["-18", "asdf"]) +def test_parse_version_spec_invalid(spec_str): + with pytest.raises(ValueError): + parse_version_spec(spec_str) + + +@pytest.mark.parametrize( + ("dep_str", "expected"), + [ + ("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'", + ( + "matplotlib", + None, + SpecifierSet(">=3.7.0,<4"), + ";sys_platform != 'win32'", + ), + ), + (f"matplotlib @ {URL}", ("matplotlib", None, urlparse(f" {URL}"), None)), + ], +) +def test_parse_pep_dependency(dep_str, expected): + assert parse_pep_dependency(dep_str) == expected diff --git a/tests/test_update_pyproject_toml.py b/tests/test_update_pyproject_toml.py index 3a013e9..fecc1e8 100644 --- a/tests/test_update_pyproject_toml.py +++ b/tests/test_update_pyproject_toml.py @@ -2,7 +2,6 @@ from unittest.mock import patch import pytest -from packaging.specifiers import SpecifierSet from packaging.version import Version from spec0_action.parsing import read_schedule, read_toml @@ -23,21 +22,14 @@ def now(cls, *args, **kwds): monkeypatch.setattr(datetime, "datetime", mydatetime) -def test_update_pyproject_toml(patch_datetime_now): - expected = read_toml("tests/test_data/pyproject_updated.toml") - pyproject_data = read_toml("tests/test_data/pyproject.toml") - test_schedule = read_schedule("tests/test_data/test_schedule.json") - update_pyproject_toml(pyproject_data, test_schedule) +@pytest.fixture +def schedule(): + return read_schedule("tests/test_data/test_schedule.json") - assert pyproject_data == expected - -def test_update_pyproject_toml_with_pixi(patch_datetime_now): - expected = read_toml("tests/test_data/pyproject_pixi_updated.toml") - pyproject_data = read_toml("tests/test_data/pyproject_pixi.toml") - test_schedule = read_schedule("tests/test_data/test_schedule.json") - update_pyproject_toml(pyproject_data, test_schedule) - assert pyproject_data == expected +@pytest.fixture(autouse=True) +def clear_pypi_cache(): + spec0_action._get_oldest_version_in_window.cache_clear() def _minimal_pyproject(*deps): @@ -49,51 +41,73 @@ def _minimal_pyproject(*deps): } -def test_update_all_updates_non_spec0_package(patch_datetime_now): +def _mock_pypi(version=None): + kwargs = {"return_value": Version(version)} if version else {} + return patch.object(spec0_action, "_get_oldest_version_in_window", **kwargs) + + +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 + + +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 + + +def test_update_all_updates_non_spec0_package(patch_datetime_now, schedule): pyproject = _minimal_pyproject("requests>=2.0.0", "numpy>=1.10.0") - schedule = read_schedule("tests/test_data/test_schedule.json") - with patch.object( - spec0_action, "_get_oldest_version_in_window", return_value=Version("2.28.0") - ): + with _mock_pypi("2.28.0"): update_pyproject_toml(pyproject, schedule, update_all=2.0) - deps = pyproject["project"]["dependencies"] - # requests is not not in SPEC 0 and should be bumped to >=2.28.0 - assert "requests>=2.28.0" in deps - # numpy is in SPEC 0 schedule so it should be handled by spec0 logic and not the flag - assert all("requests" not in d or "2.28.0" in d for d in deps) + # requests is not in SPEC 0 and is bumped from PyPI, numpy from the schedule + assert pyproject["project"]["dependencies"] == [ + "requests>=2.28.0", + "numpy>=2.0.0", + ] -def test_update_all_skips_spec0_packages(patch_datetime_now): +def test_update_all_skips_spec0_packages(patch_datetime_now, schedule): pyproject = _minimal_pyproject("numpy>=1.10.0") - schedule = read_schedule("tests/test_data/test_schedule.json") - with patch.object(spec0_action, "_get_oldest_version_in_window") as mock_pypi: + with _mock_pypi() as mock_pypi: update_pyproject_toml(pyproject, schedule, update_all=2.0) - # numpy is in the SPEC 0 schedule, _get_oldest_version_in_window must not be called for it - for call_args in mock_pypi.call_args_list: - assert call_args[0][0] != "numpy" + # 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): +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") - schedule = read_schedule("tests/test_data/test_schedule.json") - # PyPI returns an older version than what's already pinned, therefore the bound must not regress - with patch.object( - spec0_action, "_get_oldest_version_in_window", return_value=Version("2.28.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): +def test_update_all_noop_when_not_set(patch_datetime_now, schedule): pyproject = _minimal_pyproject("requests>=2.0.0", "numpy>=1.10.0") - schedule = read_schedule("tests/test_data/test_schedule.json") - with patch.object(spec0_action, "_get_oldest_version_in_window") as mock_pypi: + 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, + patch_datetime_now, schedule ): pyproject = _minimal_pyproject("requests") pyproject["project"]["optional-dependencies"] = { @@ -102,10 +116,7 @@ def test_update_all_updates_optional_dependency_groups_and_unbounded( pyproject["dependency-groups"] = { "dev": ["charset-normalizer>=3.0.0", {"include-group": "test"}], } - schedule = read_schedule("tests/test_data/test_schedule.json") - with patch.object( - spec0_action, "_get_oldest_version_in_window", return_value=Version("9.0.0") - ): + with _mock_pypi("9.0.0"): update_pyproject_toml(pyproject, schedule, update_all=2.0) assert pyproject["project"]["dependencies"] == ["requests>=9.0.0"] @@ -116,61 +127,67 @@ def test_update_all_updates_optional_dependency_groups_and_unbounded( ] -def test_self_referencing_extras_are_left_alone(patch_datetime_now): +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]"], } - schedule = read_schedule("tests/test_data/test_schedule.json") - with patch.object( - spec0_action, "_get_oldest_version_in_window", return_value=Version("2.2.2") - ) as mock_pypi: + 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]"] + 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): +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" - schedule = read_schedule("tests/test_data/test_schedule.json") update_pyproject_toml(pyproject, schedule) assert pyproject["project"]["dependencies"] == ["numpy[test]"] -def test_requires_python_preserves_existing_restrictions(patch_datetime_now): +@pytest.mark.parametrize( + ("current", "expected"), + [ + # tightened to the schedule floor, other restrictions kept + (">=3.9,<3.14,!=3.13.*", ">=3.12,<3.14,!=3.13.*"), + # missing: the schedule floor is added + (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 + ("^3.10", "^3.10"), + ], +) +def test_requires_python(patch_datetime_now, schedule, current, expected): pyproject = _minimal_pyproject() - pyproject["project"]["requires-python"] = ">=3.9,<3.14,!=3.13.*" - schedule = read_schedule("tests/test_data/test_schedule.json") + if current is None: + del pyproject["project"]["requires-python"] + else: + pyproject["project"]["requires-python"] = current update_pyproject_toml(pyproject, schedule) - assert SpecifierSet(pyproject["project"]["requires-python"]) == SpecifierSet( - ">=3.12,<3.14,!=3.13.*" - ) + assert pyproject["project"]["requires-python"] == expected -def test_requires_python_keeps_incompatible_existing_restrictions(patch_datetime_now): - pyproject = _minimal_pyproject() - pyproject["project"]["requires-python"] = ">=3.9,<3.12" - schedule = read_schedule("tests/test_data/test_schedule.json") +def test_missing_project_dependencies_is_noop(patch_datetime_now, schedule): + pyproject = {"project": {"requires-python": ">=3.9"}} update_pyproject_toml(pyproject, schedule) - assert SpecifierSet(pyproject["project"]["requires-python"]) == SpecifierSet( - ">=3.9,<3.12" - ) + assert pyproject["project"]["requires-python"] == ">=3.12" -def test_canonical_package_names_match_schedule(patch_datetime_now): +def test_canonical_package_names_match_schedule(patch_datetime_now, schedule): pyproject = _minimal_pyproject("Numpy>=1.20", "scikit_learn>=1.0") - schedule = read_schedule("tests/test_data/test_schedule.json") update_pyproject_toml(pyproject, schedule) @@ -180,7 +197,9 @@ def test_canonical_package_names_match_schedule(patch_datetime_now): ] -def test_optional_dependencies_and_dependency_groups_are_updated(patch_datetime_now): +def test_optional_dependencies_and_dependency_groups_are_updated( + patch_datetime_now, schedule +): pyproject = _minimal_pyproject() pyproject["project"]["optional-dependencies"] = { "test": ["Numpy>=1.20"], @@ -188,7 +207,6 @@ def test_optional_dependencies_and_dependency_groups_are_updated(patch_datetime_ pyproject["dependency-groups"] = { "dev": ["numpy>=1.20", {"include-group": "test"}], } - schedule = read_schedule("tests/test_data/test_schedule.json") update_pyproject_toml(pyproject, schedule) @@ -199,23 +217,31 @@ def test_optional_dependencies_and_dependency_groups_are_updated(patch_datetime_ ] -def test_missing_project_dependencies_is_noop(patch_datetime_now): - pyproject = {"project": {"requires-python": ">=3.9"}} - schedule = read_schedule("tests/test_data/test_schedule.json") +def test_url_pinned_and_up_to_date_dependencies_left_untouched( + patch_datetime_now, schedule +): + deps = [ + "scipy @ https://example.invalid/scipy.whl", # url dependency + "numpy==1.21.0", # pin conflicting with the schedule + "xarray >= 2024.1.0", # already at the schedule floor + ] + pyproject = _minimal_pyproject(*deps) update_pyproject_toml(pyproject, schedule) - assert SpecifierSet(pyproject["project"]["requires-python"]) == SpecifierSet( - ">=3.12" - ) + # All preserved byte-exact, including original whitespace + assert pyproject["project"]["dependencies"] == deps -def test_pixi_feature_pypi_dependencies_and_non_version_tables(patch_datetime_now): +def test_pixi_feature_pypi_dependencies_and_non_version_tables( + patch_datetime_now, schedule +): pyproject = _minimal_pyproject() pyproject["tool"] = { "pixi": { "dependencies": { - "scikit-learn": {"git": "https://example.invalid/scikit-learn.git"} + "scikit-learn": {"git": "https://example.invalid/scikit-learn.git"}, + "pandas": {"version": ">=1.0", "channel": "conda-forge"}, }, "feature": { "test": { @@ -227,48 +253,93 @@ def test_pixi_feature_pypi_dependencies_and_non_version_tables(patch_datetime_no }, } } - schedule = read_schedule("tests/test_data/test_schedule.json") update_pyproject_toml(pyproject, schedule) - assert ( - pyproject["tool"]["pixi"]["feature"]["test"]["pypi-dependencies"]["Numpy"] - == ">=2.0.0" - ) - assert pyproject["tool"]["pixi"]["dependencies"]["scikit-learn"] == { + pixi = pyproject["tool"]["pixi"] + assert pixi["feature"]["test"]["pypi-dependencies"]["Numpy"] == ">=2.0.0" + # version tables are updated in place, other keys kept + assert pixi["dependencies"]["pandas"] == { + "version": ">=2.2.0", + "channel": "conda-forge", + } + # git and url dependencies are not touched + assert pixi["dependencies"]["scikit-learn"] == { "git": "https://example.invalid/scikit-learn.git" } - assert pyproject["tool"]["pixi"]["feature"]["test"]["dependencies"]["xarray"] == { + assert pixi["feature"]["test"]["dependencies"]["xarray"] == { "url": "https://example.invalid/pkg.whl" } -def test_update_all_uses_version_release_date_not_new_file_upload( - patch_datetime_now, -): - class Response: - def raise_for_status(self): - pass +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"}}} + } + }, + } + } - def json(self): - return { - "files": [ - { - "filename": "example-1.0.0.tar.gz", - "upload-time": "2020-01-01T00:00:00Z", - }, - { - "filename": "example-1.0.0-py3-none-any.whl", - "upload-time": "2025-01-01T00:00:00Z", - }, - { - "filename": "example-2.0.0-py3-none-any.whl", - "upload-time": "2024-01-01T00:00:00Z", - }, - ] - } + 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" + ) - with patch.object(spec0_action.requests, "get", return_value=Response()): + +def test_update_all_uses_version_release_date_not_new_file_upload(patch_datetime_now): + response = _pypi_response( + [ + { + "filename": "example-1.0.0.tar.gz", + "upload-time": "2020-01-01T00:00:00Z", + }, + { + "filename": "example-1.0.0-py3-none-any.whl", + "upload-time": "2025-01-01T00:00:00Z", + }, + { + "filename": "example-2.0.0-py3-none-any.whl", + "upload-time": "2024-01-01T00:00:00Z", + }, + ] + ) + + with patch.object(spec0_action.requests, "get", return_value=response): assert spec0_action._get_oldest_version_in_window("example", 2) == Version( "2.0.0" ) + + +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", + } + ] + ) + + 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): + 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"] + assert pyproject["project"]["dependencies"] == ["Demo_Pkg>=2.0.0"] + assert pyproject["dependency-groups"]["dev"] == ["demo-pkg>=2.0.0"] diff --git a/tests/test_versions.py b/tests/test_versions.py index 80ac2f8..302dee6 100644 --- a/tests/test_versions.py +++ b/tests/test_versions.py @@ -9,30 +9,31 @@ def test_repr_specset(): assert repr_spec_set(spec) == "~=3.14,>4,<7,!=3.8.0" -def test_tighter_lower_bound_any(): - spec = SpecifierSet(">=0") - lower_bound = Version("3.8.0") - tightened = tighten_lower_bound(spec, lower_bound) - assert tightened == SpecifierSet(">=3.8.0") - - -def test_tighter_lower_bound_leaves_other_restrictions(): - spec = SpecifierSet(">=1.0,!= 1.3.4.*,< 2.0") - lower_bound = Version("1.4.0") - tightened = tighten_lower_bound(spec, lower_bound) - assert tightened == SpecifierSet(">=1.4.0,!=1.3.4.*,<2.0") - - -def test_tighter_lower_bound_adds_lower_bound_if_not_present(): - spec = SpecifierSet("!=1.3.4.*,<2.0") - lower_bound = Version("1.4.0") - tightened = tighten_lower_bound(spec, lower_bound) - assert tightened == SpecifierSet("!=1.3.4.*,<2.0,>=1.4.0") - - -def test_tighter_lower_bound_rejects_incompatible_restrictions(): - spec = SpecifierSet(">=1.0,<2.0") - lower_bound = Version("2.0.0") - - with pytest.raises(ValueError): - tighten_lower_bound(spec, lower_bound) +@pytest.mark.parametrize( + ("spec", "bound", "expected"), + [ + # any-version spec gets the new floor + (">=0", "3.8.0", ">=3.8.0"), + # exclusive lower bound is replaced as well + (">1.0", "1.4.0", ">=1.4.0"), + # other restrictions are kept + (">=1.0,!= 1.3.4.*,< 2.0", "1.4.0", ">=1.4.0,!=1.3.4.*,<2.0"), + # floor added when absent + ("!=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 + (">=1.0,<2.0", "2.0.0", None), + # pinned versions can't be tightened + ("==1.21.0", "2.0.0", None), + # existing bound already stricter + (">=2.5", "2.0.0", None), + ], +) +def test_tighten_lower_bound(spec, bound, expected): + result = tighten_lower_bound(SpecifierSet(spec), Version(bound)) + assert result == (None if expected is None else SpecifierSet(expected))