diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..887a2c1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# SCM syntax highlighting & preventing 3-way merges +pixi.lock merge=binary linguist-language=YAML linguist-generated=true diff --git a/.github/workflows/release_schedule.yaml b/.github/workflows/release_schedule.yaml new file mode 100644 index 0000000..0e79fe8 --- /dev/null +++ b/.github/workflows/release_schedule.yaml @@ -0,0 +1,52 @@ +name: Generate release schedule artifacts +on: + schedule: + # At 00:00 on day-of-month 1 in every 3rd month. (i.e. every quarter) + - cron: "0 0 1 */3 *" + # On demand + workflow_dispatch: + +jobs: + create-artifacts: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # We're going to make a tag that we can we release so we'll need the full history for that + fetch-depth: 0 + - name: Set up Python + uses: actions/setup-python@v5 + + - uses: prefix-dev/setup-pixi@v0.8.14 + with: + pixi-version: "v0.49.0" + environments: schedule + - name: Run spec_zero_versions.py + run: | + pixi run -e schedule generate_schedule + - name: setup git + run: | + # git will complain if we don't do this first + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: determine tag name + id: tag_name + run: | + echo "TAG_NAME=$(date '+%Y-Q%q')" >> "$GITHUB_OUTPUT" + + - name: Publish github release + uses: softprops/action-gh-release@v2 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + generate_release_notes: true + tag_name: ${{ steps.tag_name.outputs.TAG_NAME }} + make_latest: true + files: | + schedule.md + chart.md + schedule.json diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..e926d01 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,22 @@ +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@v4 + + - uses: prefix-dev/setup-pixi@v0.8.14 + with: + pixi-version: "v0.49.0" + environments: dev + - run: | + pixi run test diff --git a/.github/workflows/test_action.yaml b/.github/workflows/test_action.yaml index 03fb7c1..fd2c7a0 100644 --- a/.github/workflows/test_action.yaml +++ b/.github/workflows/test_action.yaml @@ -4,30 +4,14 @@ on: [push, pull_request] jobs: generate_data: runs-on: ubuntu-latest - name: Generate version data + name: Update test project file 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@v5 - 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 + diff --git a/.gitignore b/.gitignore index d7a22f5..e8f5b39 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .history -chart.md -schedule.md -schedule.json +# pixi environments +.pixi/* +!.pixi/config.toml +pixi.lock +__pycache__ diff --git a/action.yaml b/action.yaml index ab54c11..1c558dd 100644 --- a/action.yaml +++ b/action.yaml @@ -1,26 +1,72 @@ name: "Generate SPEC-0000 Data" description: "Based on the current SPEC 0 schedule, generate a tarball with the latest versions of all packages." +author: Scientific Python Developers +inputs: + target_branch: + description: "Target branch for the pull request" + required: true + default: "main" + project_file_name: + description: "The filename to the project file that lists your dependencies, relative to the repository root. Defaults to 'pyproject.toml' Curretnly only pyproject.toml is supported but others may be added." + required: true + default: "pyproject.toml" + create_pr: + description: "Whether the action should open a PR or not. If this is set to false, this action should have no effect. This is mainly useful for testing" + required: true + default: true + schedule_path: + description: "Path to the schedule.json file. If it does not exist yet, it will be downloaded from the spec0-action repository latest release" + 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@v5 + - name: Checkout code + uses: actions/checkout@v4 with: - python-version: "3.13" - - name: Install dependencies + fetch-depth: 0 + + - name: Set up Git + shell: bash + run: | + git config user.name "Scientific Python [bot]" + git config user.email "scientific-python@users.noreply.github.com" + + - name: Download schedule artifact shell: bash + env: + SCHEDULE_FILE: ${{ inputs.schedule_path }} run: | - pip install -r requirements.txt - - name: Run spec_zero_versions.py + if [ ! -f "$SCHEDULE_FILE" ]; then + gh release download -R "scientific-python/spec0-action" --pattern schedule.json -O "$SCHEDULE_FILE" + fi + + - uses: prefix-dev/setup-pixi@v0.8.14 + name: Setup pixi + with: + environments: >- + update + activate-environment: update + pixi-version: v0.49.0 + manifest-path: ${{github.action_path}}/pyproject.toml + + - name: Run Update script shell: bash run: | - python spec_zero_versions.py - - name: Upload files as an artifact - uses: actions/upload-artifact@v4 + python ${{github.action_path}}/spec0-update.py "${{github.workspace}}/${{inputs.project_file_name}}" "${{inputs.schedule_path}}" + # let's cleanup after ourselves so it doesn't end up in the PR + rm "${{inputs.schedule_path}}" + + - name: Create Pull Request + if: ${{ inputs.create_pr == 'true' }} + uses: peter-evans/create-pull-request@v6 with: - name: spec-zero-versions - path: | - schedule.json - schedule.md - chart.md + token: ${{ inputs.token }} + commit-message: "chore: Drop support for unsupported packages conform SPEC 0" + title: "Drop support for unsupported packages conform SPEC 0" + body: "This PR was created automatically" + base: ${{ inputs.target_branch }} + branch: update-spec-0-dependencies-${{ github.run_id }} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..fb1c998 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,38 @@ +[project] +authors = [{ name = "Scientific Python Developers" }] +name = "spec0-action" +requires-python = ">= 3.11" +version = "0.1.0" +dependencies = ["packaging>=25.0"] + +[build-system] +build-backend = "hatchling.build" +requires = ["hatchling"] + +[tool.pixi.workspace] +channels = ["conda-forge"] +platforms = ["linux-64"] + +[tool.pixi.feature.update.pypi-dependencies] +spec0-action = { path = ".", editable = true } + +[tool.pixi.feature.test.tasks] +test = { cmd = ["pytest", "-vvv"] } + +[tool.pixi.feature.schedule.tasks] +generate-schedule = { cmd = ["python", "spec0-verions.py"] } + +[tool.pixi.feature.test.dependencies] +pytest = "*" + +[tool.pixi.feature.update.dependencies] +tomlkit = ">=0.13.3,<0.14" + +[tool.pixi.feature.schedule.dependencies] +pandas = "*" +requests = "*" + +[tool.pixi.environments] +dev = ["test", "schedule", "update"] +schedule = ["schedule"] +update = ["update"] diff --git a/readme.md b/readme.md index c05545c..e88f013 100644 --- a/readme.md +++ b/readme.md @@ -1,63 +1,51 @@ # 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 such that they conform to the SPEC 0 support schedule. +It also contains released versions of the schedule in various formats that that action can use to open PRs in your repository. ## Using the action +To use the action you can copy the yaml below, and paste it into `.github/workflows/update-spec-0.yaml`. +The arguments below are filled with their default value, in most cases you won't have to fill them. +All except for `token` are optional. + +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 + workflow_dispatch: + schedule: + # At 00:00 on day-of-month 2 in every 3rd month. (i.e. every quarter) + # Releases should happen on the first day of the month in scientific-python/spec-zero-tools + # so allow one day as a buffer to avoid timing issues + - cron: "0 0 2 */3 *" + +permissions: + contents: write + pull-requests: write jobs: - devstats-query: + update: runs-on: ubuntu-latest steps: - - uses: scientific-python/spec0-action@main + - uses: scientific-python/spec0-action@v1 + with: + token: ${{ secrets.GH_PAT }} + project_file_name: "pyproject.toml" + target_branch: 'main' ``` -The above would produce an artifact named `spec-zero-versions`, the following files: `schedule.yaml`,`schedule.md` and `chart.md`. - -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: +It should update any of the packages listed in the `dependency`, or `tool.pixi.*` tables. -```json -[ - { - "start_date": "iso8601_timestamp", - "packages": { - "package_name": "version" - } - } -] -``` +## Limitations -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: +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 be adjusted manually. +2. Currently only `pyproject.toml` is supported by this action, though other manifest files could be considered upon request. -1. Fetch `schedule.json` -2. Determine maximum date that is smaller than current date -3. Update packages listed with new minimum 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 -``` - -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/schedule.json b/schedule.json new file mode 100644 index 0000000..c848ede --- /dev/null +++ b/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"}] \ No newline at end of file diff --git a/spec0-update.py b/spec0-update.py new file mode 100644 index 0000000..5f2fa4b --- /dev/null +++ b/spec0-update.py @@ -0,0 +1,28 @@ +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( + prog='spec_zero_update', + 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") + + 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/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/spec0_action/__init__.py b/spec0_action/__init__.py new file mode 100644 index 0000000..5e6eea0 --- /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 oudated." + ) + + 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..096b049 --- /dev/null +++ b/spec0_action/parsing.py @@ -0,0 +1,80 @@ +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: + 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/tests/test_data/pyproject.toml b/tests/test_data/pyproject.toml new file mode 100644 index 0000000..8bcee20 --- /dev/null +++ b/tests/test_data/pyproject.toml @@ -0,0 +1,29 @@ +[project] +authors = [{ name = "Sam Vente", email = "savente93@proton.me" }] +name = "tests" +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..1fee189 --- /dev/null +++ b/tests/test_data/pyproject_updated.toml @@ -0,0 +1,29 @@ +[project] +authors = [{ name = "Sam Vente", email = "savente93@proton.me" }] +name = "tests" +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..c5b23bf --- /dev/null +++ b/tests/test_parsing.py @@ -0,0 +1,65 @@ +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") + + +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")