diff --git a/.github/workflows/test_action.yaml b/.github/workflows/test_action.yaml index 2bb63ca..5aa6d6e 100644 --- a/.github/workflows/test_action.yaml +++ b/.github/workflows/test_action.yaml @@ -4,30 +4,13 @@ on: [push, pull_request] jobs: generate_data: runs-on: ubuntu-latest - name: Generate version data + name: Run action on test file in repo steps: - name: Checkout uses: actions/checkout@v5 - name: Generate version data using local action uses: ./ - - name: Check file contents - run: | - printf "Contents of chart.md:\n" - cat chart.md - printf "\n\n" - printf "Contents of schedule.json:\n" - cat schedule.json - printf "\n\n" - printf "Contents of schedule.md:\n" - cat schedule.md - printf "\n\n" - - name: Remove generated files - run: | - printf "Removing generated files...\n" - rm -f chart.md schedule.json schedule.md - ls -R - - uses: actions/download-artifact@v6 - with: - name: spec-zero-versions - - name: Display structure of downloaded files - run: ls -R + with: + project_file_name: tests/test_data/pyproject.toml + create_pr: false + schedule_path: tests/test_data/test_schedule.json \ No newline at end of file diff --git a/.github/workflows/test_bench.yml b/.github/workflows/test_bench.yml new file mode 100644 index 0000000..73dae60 --- /dev/null +++ b/.github/workflows/test_bench.yml @@ -0,0 +1,21 @@ +name: Run the update test suite +on: + push: + branches: main + pull_request: + branches: main + # On demand + workflow_dispatch: + +jobs: + run-tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v5 + + - uses: prefix-dev/setup-pixi@v0.9.0 + with: + pixi-version: "v0.49.0" + - run: | + pixi run test diff --git a/.gitignore b/.gitignore index d7a22f5..82c7512 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ chart.md schedule.md schedule.json +__pycache__ +*.pyc +*.lock diff --git a/action.yaml b/action.yaml index 42cceac..4ad0c69 100644 --- a/action.yaml +++ b/action.yaml @@ -1,26 +1,98 @@ -name: "Generate SPEC-0000 Data" -description: "Based on the current SPEC 0 schedule, generate a tarball with the latest versions of all packages." +name: "Update SPEC 0 dependencies" +description: "Update the lower bounds of Python dependencies covered by the Scientific Python SPEC 0 support schedule" +author: Scientific Python Developers +inputs: + target_branch: + description: "Target branch for the pull request" + required: true + default: "main" + project_file_name: + description: "Path to the project file listing dependencies, relative to repository root. Defaults to 'pyproject.toml'. Currently only pyproject.toml is supported." + required: true + default: "pyproject.toml" + create_pr: + description: "Whether the action should open a PR or not. Set to false for dry-run/testing." + required: true + default: true + commit_msg: + description: "Commit message for the commit to update the versions. by default 'Drop support for unsupported packages conform SPEC 0'. has no effect if `create_pr` is set to false" + required: false + default: "chore: Drop support for unsupported packages conform SPEC 0" + pr_title: + description: "The title of the PR that will be opened. by default 'Drop support for unsupported packages conform SPEC 0'. has no effect if `create_pr` is set to false" + required: false + default: "chore: Drop support for unsupported packages conform SPEC 0" + schedule_path: + description: "Path to the schedule.json file relative to the project root. If missing, it will be downloaded from the latest release of savente93/SPEC0-schedule" + default: "schedule.json" + token: + description: "GitHub token with repo permissions to create pull requests" + required: true runs: using: "composite" steps: - - name: Set up Python - uses: actions/setup-python@v6 + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Git + shell: bash + run: | + git config user.name "Scientific Python [bot]" + git config user.email "scientific-python@users.noreply.github.com" + + - uses: prefix-dev/setup-pixi@v0.9.0 + name: Setup Pixi with: - python-version: "3.13" - - name: Install dependencies + pixi-version: v0.49.0 + manifest-path: ${{ github.action_path }}/pyproject.toml + + - name: Regenerate schedule file if necessary + shell: bash + env: + SCHEDULE_FILE: ${{ inputs.schedule_path }} + GH_TOKEN: ${{ inputs.token }} + run: | + set -e + if [ ! -f "${{ github.workspace }}/$SCHEDULE_FILE" ]; then + echo "Regenerating schedule.json..." + pixi run generate-schedule --locked + if diff -q schedule.json "${{ github.workspace }}/$SCHEDULE_FILE" >/dev/null; then + echo "Source and destination have identical contents – nothing to move." + else + mv schedule.json "${{ github.workspace }}/$SCHEDULE_FILE" + fi + else + echo "Schedule file already exists at $SCHEDULE_FILE" + fi + + + - name: Run update script shell: bash run: | - pip install -r requirements.txt - - name: Run spec_zero_versions.py + set -e + echo "Updating ${{inputs.project_file_name}} using schedule ${{inputs.schedule_path}}" + pixi run --manifest-path ${{ github.action_path }}/pyproject.toml update-dependencies "${{ github.workspace }}/${{ inputs.project_file_name }}" "${{ github.workspace }}/${{ inputs.schedule_path }}" + + - name: Show changes (dry-run) + if: ${{ inputs.create_pr != 'true' }} shell: bash run: | - python spec_zero_versions.py - - name: Upload files as an artifact - uses: actions/upload-artifact@v5 + echo "Dry run: showing changes that would be committed" + git --no-pager diff ${{ inputs.project_file_name }} + + - name: Create Pull Request + if: ${{ inputs.create_pr == 'true' }} + uses: peter-evans/create-pull-request@v7 with: - name: spec-zero-versions - path: | - schedule.json - schedule.md - chart.md + token: ${{ inputs.token }} + commit-message: ${{ inputs.commit_msg }} + path: ${{ inputs.project_file_name }} + title: ${{ inputs.pr_title }} + body: "This PR was created automatically" + base: ${{ inputs.target_branch }} + branch: update-spec0-dependencies-${{ github.run_id }} + +branding: + icon: 'check-square' + color: 'blue' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..946a3b2 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,33 @@ +[project] +authors = [{ name = "Scientific Python Developers" }] +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"] + +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[tool.pixi.workspace] +channels = ["conda-forge"] +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"] } +generate-schedule = { cmd = ["python", "spec0_versions.py"] } + +[tool.pixi.feature.test.tasks] +test = { cmd = ["pytest", "-vvv"] } + +[tool.pixi.feature.test.dependencies] +pytest = "*" + +[tool.pixi.environments] +test = ["test"] + diff --git a/readme.md b/readme.md index c05545c..6209dc5 100644 --- a/readme.md +++ b/readme.md @@ -1,63 +1,62 @@ -# SPEC-0 Versions Action +# SPEC 0 Versions Action -This repository contains a GitHub Action to generate the files required for the SPEC-0 documentation. +This repository contains a Github Action to update Python dependencies in your `pyproject.toml` such that they conform to the SPEC 0 support schedule. You can find this schedule [here](https://scientific-python.org/specs/spec-0000/) ## Using the action + +### Example workflow + +To use the action you can copy the yaml below, and paste it into `.github/workflows/update-spec0.yaml`. Whenever the action is triggered it will open a PR in your repository that will update the dependencies of SPEC 0 to the new lower bound. For this you will have to provide it with a PAT that has write permissions in the `contents` and `pull request` scopes. Please refer to the GitHub documentation for instructions on how to do this [here](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens). + + ```yaml -name: Generate spec-zero data +name: Update SPEC 0 dependencies on: - push: - branches: - - main + schedule: + # At 00:00 on day-of-month 3 in every 3rd month. (i.e. every quarter) + # Releases should happen on the second day of the quarter in savente93/SPEC0-schedule to + # avoid fence post errors, so allow one day as a buffer to avoid timing issues here as well. + - cron: "0 0 3 */3 *" + # On demand: + workflow_dispatch: + +permissions: + contents: write + pull-requests: write jobs: - devstats-query: + update: runs-on: ubuntu-latest steps: - - uses: scientific-python/spec0-action@main + - uses: savente93/update-spec0-dependencies@v1.0.0 + with: + token: ${{ secrets.GH_PAT }} # <- GH_PAT you will have to configure in the repo as a secret ``` -The above would produce an artifact named `spec-zero-versions`, the following files: `schedule.yaml`,`schedule.md` and `chart.md`. +It should update any of the packages listed in the `dependency`, or `tool.pixi.*` tables. For examples of before and after you can see [./tests/test_data/pyproject.toml](./tests/test_data/pyproject.toml) and [./tests/test_data/pyproject_updated.toml](./tests/test_data/pyproject_updated.toml) respectively. Other tools are not yet supported, but I am open to feature requests. -To help projects stay compliant with SPEC-0, we provide a `schedule.json` file that can be used by CI systems to determine new version boundaries. -The structure of the file is as follows: +The newest lower bounds will be downloaded from [https://github.com/savente93/SPEC0-schedule](https://github.com/savente93/SPEC0-schedule) but you should not have to worry about this. -```json -[ - { - "start_date": "iso8601_timestamp", - "packages": { - "package_name": "version" - } - } -] -``` -All information in the json file is in a string format that should be easy to use. -The date is the first timestamp of the relevant quarter. -Thus a workflow for using this file could be: +### Parameters -1. Fetch `schedule.json` -2. Determine maximum date that is smaller than current date -3. Update packages listed with new minimum versions +| Input | Required | Default | Description | +| ------------------- | -------- | ------------------ | -------------------------------------------------------------------------------| +| token | yes | — | Personal access token with `contents` & `pull-request` scopes | +| project\_file\_name | no | `"pyproject.toml"` | File to update dependencies in | +| schedule\_path | no | `"schedule.json"` | path to schedule json data. only relevant if you have it committed in your repo | +| target\_branch | no | `"main"` | Branch to open PR against | +| create_pr | no | `true` | Open a PR with new versions | +| pr_title | no | `chore: Drop support for unsupported packages conform SPEC 0` | The title of the PR that will be opened | +| commit_msg | no | `chore: Drop support for unsupported packages conform SPEC 0` | Commit message of the commit to update the versions. | -You can obtain the new versions you should set by using this `jq` expression: -```sh -jq 'map(select(.start_date |fromdateiso8601 |tonumber < now))| sort_by("start_date") | reverse | .[0].packages ' schedule.json -``` +## Limitations + +1. Since this action simply parses the toml to do the upgrade and leaves any other bounds intact, it is possible that the environment of the PR becomes unsolvable. + For example if you have a numpy dependency like so: `numpy = ">=1.25.0,<2"` this will get updated in the PR to `numpy = ">=2.0.0,<2"` which is infeasible. + Keeping the resulting environment solvable is outside the scope of this action, so you might have to adjust them manually. +2. Currently only `pyproject.toml` is supported by this action, though other manifest files could be considered upon request. -If you use a package manager like pixi you could update the dependencies with a bash script like this (untested): - -```sh -curl -Ls -o schedule.json https://raw.githubusercontent.com/scientific-python/specs/main/spec-0000/schedule.json -for line in $(jq 'map(select(.start_date |fromdateiso8601 |tonumber < now))| sort_by("start_date") | reverse | .[0].packages | to_entries | map(.key + ":" + .value)[]' --raw-output schedule.json); do - package=$(echo "$line" | cut -d ':' -f 1) - version=$(echo "$line" | cut -d ':' -f 2) - if pixi list -x "^$package" &>/dev/null| grep "No packages" -q; then - pixi add "$package>=$version"; - fi -done -``` diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index ccaba93..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -packaging -pandas -requests diff --git a/run_spec0_update.py b/run_spec0_update.py new file mode 100644 index 0000000..b264792 --- /dev/null +++ b/run_spec0_update.py @@ -0,0 +1,29 @@ +from spec0_action import update_pyproject_toml, read_toml, write_toml, read_schedule +from pathlib import Path +from argparse import ArgumentParser + + +if __name__ == '__main__': + parser = ArgumentParser( + description='A script to update your project dependencies to be in line with the scientific python SPEC 0 support schedule', + ) + + parser.add_argument('toml_path', default="pyproject.toml", help="Path to the project file that lists the dependencies. defaults to 'pyproject.toml'.") + parser.add_argument('schedule_path', default="schedule.json", help="Path to the schedule json payload. defaults to 'schedule.json'") + + args = parser.parse_args() + + toml_path = Path(args.toml_path) + schedule_path = Path(args.schedule_path) + + if not toml_path.exists(): + raise ValueError(f"{toml_path} was supplied as path to project file but it did not exist") + + if not schedule_path.exists(): + raise ValueError(f"{schedule_path} was supplied as path to schedule file but it did not exist") + + project_data = read_toml(toml_path) + schedule_data = read_schedule(schedule_path) + update_pyproject_toml(project_data, schedule_data) + + write_toml(toml_path, project_data) diff --git a/spec0_action/__init__.py b/spec0_action/__init__.py new file mode 100644 index 0000000..e3bce72 --- /dev/null +++ b/spec0_action/__init__.py @@ -0,0 +1,119 @@ +from packaging.specifiers import SpecifierSet +from typing import Sequence, cast +import datetime + +from spec0_action.versions import repr_spec_set, tighten_lower_bound +from spec0_action.parsing import ( + SupportSchedule, + Url, + is_url_spec, + parse_pep_dependency, + parse_version_spec, + read_schedule, + read_toml, + write_toml +) +from packaging.version import Version + + +__all__ = ["read_schedule", "read_toml", "write_toml", "update_pyproject_toml"] + +def update_pyproject_dependencies(dependencies: dict, schedule: SupportSchedule): + # Iterate by idx because we want to update it inplace + for i in range(len(dependencies)): + dep_str = dependencies[i] + pkg, extras, spec = parse_pep_dependency(dep_str) + + if isinstance(spec, Url) or pkg not in schedule["packages"]: + continue + + new_lower_bound = Version(schedule["packages"][pkg]) + 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: + continue + + if not extras: + new_dep_str = f"{pkg}{repr_spec_set(spec)}" + else: + new_dep_str = f"{pkg}{extras}{repr_spec_set(spec)}" + + dependencies[i] = new_dep_str + + +def update_dependency_table(dep_table: dict, new_versions: dict): + for pkg, pkg_data in dep_table.items(): + # Don't do anything for pkgs that aren't in our schedule + if pkg not in new_versions: + continue + + # Like pkg = ">x.y.z,= datetime.datetime.fromisoformat(s["start_date"]), + schedule_data, + ) + ), + ) + except StopIteration: + raise RuntimeError( + "Could not find schedule that applies to current time, perhaps your schedule is outdated." + ) + + if "python" in new_version["packages"]: + pyproject_data["project"]["requires-python"] = repr_spec_set( + parse_version_spec(new_version["packages"]["python"]) + ) + + update_pyproject_dependencies( + pyproject_data["project"]["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) diff --git a/spec0_action/parsing.py b/spec0_action/parsing.py new file mode 100644 index 0000000..1f5ad4d --- /dev/null +++ b/spec0_action/parsing.py @@ -0,0 +1,88 @@ +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 pathlib import Path +from re import compile + +# We won't actually do anything with URLs we just need to detect them +Url: TypeAlias = ParseResult + +# Slightly modified version of https://packaging.python.org/en/latest/specifications/dependency-specifiers/#names +PEP_PACKAGE_IDENT_RE = compile(r"(?im)^([A-Z0-9][A-Z0-9._-]*)(\[[A-Z0-9._,-]+\])?(.*)$") + + +class SupportSchedule(TypedDict): + start_date: str + packages: Dict[str, str] + + +def parse_version_spec(s: str) -> SpecifierSet: + if s.strip() == "*": + # Python version numeric components must be non-negative so this is okay + # 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: + ver = Version(s) + except InvalidVersion: + if "*" in s: + # pixi sometimes uses things like python = "3.11.*" + try: + return SpecifierSet(f"=={s}") + except InvalidVersion: + # if we don't return later raise is the same + pass + + raise ValueError(f"{s} is not a version or specifyer") + + return SpecifierSet(f">={ver}") + + +def write_toml(path: Path | str, data: dict): + with open(path, "w") as file: + contents = dumps(data) + file.write(contents) + + +def read_toml(path: Path | str) -> dict: + with open(path, "r") as file: + contents = file.read() + return loads(contents) + + +def read_schedule(path: Path | str) -> Sequence[SupportSchedule]: + with open(path, "r") as file: + return json.load(file) + + +def parse_pep_dependency(dep_str: str) -> Tuple[str, str | None, SpecifierSet | Url | None]: + match = PEP_PACKAGE_IDENT_RE.match(dep_str) + if match is None: + raise ValueError("Could not find any valid python package identifier") + + pkg, extras, spec_str = match.groups() + + extras = extras or None + + if is_url_spec(spec_str): + spec = urlparse(spec_str.split("@")[1]) + elif not spec_str: + spec = None + else: + spec = SpecifierSet(spec_str) + + return (pkg, extras, spec) + + +def is_url_spec(str_spec: str|None) -> bool: + if str_spec is None: + return False + + return str_spec.strip().startswith("@") diff --git a/spec0_action/versions.py b/spec0_action/versions.py new file mode 100644 index 0000000..f88748e --- /dev/null +++ b/spec0_action/versions.py @@ -0,0 +1,28 @@ +from packaging.version import Version +from packaging.specifiers import Specifier, SpecifierSet + + +def tighten_lower_bound( + spec_set: SpecifierSet, new_lower_bound: Version +) -> SpecifierSet: + 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: + out.append(Specifier(f">={new_lower_bound}")) + contains_lower_bound = True + else: + raise ValueError(f"{spec} is already stricter than {new_lower_bound}") + + if not contains_lower_bound: + out.append(Specifier(f">={new_lower_bound}")) + + return SpecifierSet(out) + + +def repr_spec_set(spec: SpecifierSet) -> str: + return ",".join(sorted(map(str, spec), reverse=True)).replace(" ", "") diff --git a/spec_zero_versions.py b/spec0_versions.py similarity index 100% rename from spec_zero_versions.py rename to spec0_versions.py diff --git a/tests/test_data/pyproject.toml b/tests/test_data/pyproject.toml new file mode 100644 index 0000000..b06e4c5 --- /dev/null +++ b/tests/test_data/pyproject.toml @@ -0,0 +1,30 @@ +[project] +authors = [{ name = "Scientific Python Developers"}] +name = "tests" +description = "This is just a dummy package for testing the spec 0 update github action and should not be used" +requires-python = ">=3.10" +version = "0.1.0" +dependencies = ["ipython>=8.7.0,<4", "numpy[foo,bar]>=1.10.0,<2"] + +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[tool.pixi.workspace] +channels = ["conda-forge"] +platforms = ["linux-64"] + +[tool.pixi.pypi-dependencies] +tests = { path = ".", editable = true } +scikit-learn = ">=1.2.0" + +[tool.pixi.tasks] + +[tool.pixi.feature.foo.dependencies] +xarray = "*" + +[tool.pixi.environments] +bar = ["foo"] + +[tool.pixi.dependencies] +numpy = ">=1.10.0,<2" diff --git a/tests/test_data/pyproject_updated.toml b/tests/test_data/pyproject_updated.toml new file mode 100644 index 0000000..a780ef7 --- /dev/null +++ b/tests/test_data/pyproject_updated.toml @@ -0,0 +1,30 @@ +[project] +authors = [{ name = "Scientific Python Developers"}] +name = "tests" +description = "This is just a dummy package for testing the spec 0 update github action and should not be used" +requires-python = ">=3.11" +version = "0.1.0" +dependencies = ["ipython>=8.8.0,<4", "numpy[foo,bar]>=1.25.0,<2"] + +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[tool.pixi.workspace] +channels = ["conda-forge"] +platforms = ["linux-64"] + +[tool.pixi.pypi-dependencies] +tests = { path = ".", editable = true } +scikit-learn = ">=1.3.0" + +[tool.pixi.tasks] + +[tool.pixi.feature.foo.dependencies] +xarray = ">=2023.1.0" + +[tool.pixi.environments] +bar = ["foo"] + +[tool.pixi.dependencies] +numpy = ">=1.25.0,<2" diff --git a/tests/test_data/test_schedule.json b/tests/test_data/test_schedule.json new file mode 100644 index 0000000..ec6c35e --- /dev/null +++ b/tests/test_data/test_schedule.json @@ -0,0 +1 @@ +[{"packages": {"ipython": "8.8.0", "numpy": "1.25.0", "python": "3.11", "scikit-learn": "1.3.0", "xarray": "2023.1.0"}, "start_date": "2024-10-01T00:00:00Z"}, {"packages": {"ipython": "8.13.0", "matplotlib": "3.8.0", "networkx": "3.1", "scikit-image": "0.21.0", "scipy": "1.11.0", "xarray": "2023.4.0", "zarr": "2.15.0"}, "start_date": "2025-01-01T00:00:00Z"}, {"packages": {"ipython": "8.15.0", "networkx": "3.2", "numpy": "1.26.0", "pandas": "2.1.0", "scikit-image": "0.22.0", "scikit-learn": "1.4.0", "scipy": "1.12.0", "xarray": "2023.7.0", "zarr": "2.16.0"}, "start_date": "2025-04-01T00:00:00Z"}, {"packages": {"ipython": "8.17.0", "matplotlib": "3.9.0", "numpy": "2.0.0", "pandas": "2.2.0", "xarray": "2023.10.0", "zarr": "2.17.0"}, "start_date": "2025-07-01T00:00:00Z"}, {"packages": {"ipython": "8.20.0", "networkx": "3.3", "python": "3.12", "scikit-image": "0.23.0", "xarray": "2024.1.0"}, "start_date": "2025-10-01T00:00:00Z"}, {"packages": {"ipython": "8.24.0", "pandas": "2.3.0", "scikit-learn": "1.5.0", "scipy": "1.13.0", "xarray": "2024.5.0", "zarr": "2.18.0"}, "start_date": "2026-01-01T00:00:00Z"}, {"packages": {"ipython": "8.27.0", "matplotlib": "3.10.0", "networkx": "3.4", "numpy": "2.1.0", "scikit-image": "0.25.0", "scikit-learn": "1.6.0", "scipy": "1.15.0", "xarray": "2024.7.0", "zarr": "3.0.0"}, "start_date": "2026-04-01T00:00:00Z"}, {"packages": {"ipython": "8.28.0", "numpy": "2.2.0", "xarray": "2024.10.0"}, "start_date": "2026-07-01T00:00:00Z"}, {"packages": {"ipython": "8.32.0", "networkx": "3.5", "numpy": "2.3.0", "python": "3.13", "scikit-learn": "1.7.0", "xarray": "2025.1.0"}, "start_date": "2026-10-01T00:00:00Z"}, {"packages": {"ipython": "9.1.0", "scipy": "1.16.0", "xarray": "2025.4.0", "zarr": "3.1.0"}, "start_date": "2027-01-01T00:00:00Z"}, {"packages": {"ipython": "9.4.0", "xarray": "2025.7.0"}, "start_date": "2027-04-01T00:00:00Z"}] diff --git a/tests/test_parsing.py b/tests/test_parsing.py new file mode 100644 index 0000000..34afda3 --- /dev/null +++ b/tests/test_parsing.py @@ -0,0 +1,66 @@ +from spec0_action.parsing import parse_version_spec, parse_pep_dependency +from packaging.specifiers import SpecifierSet +import pytest +from urllib.parse import urlparse + + +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 = parse_pep_dependency(matplotlib_str) + + assert pkg == "matplotlib", pkg + assert features is None, features + assert spec is None, spec + + +def test_pep_dependency_parsing_with_spec_and_optional_dep(): + matplotlib_str = "matplotlib[foo,bar]>=3.7.0,<4" + pkg, features, spec = parse_pep_dependency(matplotlib_str) + + assert pkg == "matplotlib", pkg + assert features == "[foo,bar]", features + assert spec == SpecifierSet(">=3.7.0,<4"), spec + +def test_pep_dependency_parsing_with_spec(): + matplotlib_str = "matplotlib>=3.7.0,<4" + pkg, features, spec = parse_pep_dependency(matplotlib_str) + + assert pkg == "matplotlib", pkg + assert features is None, features + assert spec == SpecifierSet(">=3.7.0,<4"), spec + + +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 = 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 + + +def test_pep_dependency_parsing_extra_restrictions(): + matplotlib_str = "matplotlib>=3.7.0,<4,!=3.8.14" + pkg, features, spec = 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 diff --git a/tests/test_update_pyproject_toml.py b/tests/test_update_pyproject_toml.py new file mode 100644 index 0000000..40397fb --- /dev/null +++ b/tests/test_update_pyproject_toml.py @@ -0,0 +1,11 @@ +from spec0_action.parsing import read_schedule, read_toml +from spec0_action import update_pyproject_toml + + +def test_update_pyproject_toml(): + 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) + + assert pyproject_data == expected diff --git a/tests/test_versions.py b/tests/test_versions.py new file mode 100644 index 0000000..e364f7b --- /dev/null +++ b/tests/test_versions.py @@ -0,0 +1,29 @@ +from packaging.version import Version +from spec0_action.versions import repr_spec_set, tighten_lower_bound +from packaging.specifiers import SpecifierSet + + +def test_repr_specset(): + spec = SpecifierSet("<7,!=3.8.0,>4,~=3.14") + 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("~= 0.9,>=1.0,!= 1.3.4.*,< 2.0") + lower_bound = Version("3.8.0") + tightened = tighten_lower_bound(spec, lower_bound) + assert tightened == SpecifierSet("~= 0.9,>=3.8.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") + tightened = tighten_lower_bound(spec, lower_bound) + assert tightened == SpecifierSet("~= 0.9, != 1.3.4.*, < 2.0, >=3.8.0")