diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf26143..a99cedf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,11 +5,16 @@ on: pull_request: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: ci: name: tests runs-on: [ubuntu-latest] strategy: + fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12", "3.13"] @@ -36,16 +41,51 @@ jobs: runs-on: [ubuntu-latest] strategy: + fail-fast: false matrix: env-paths: - ["envs/env1.yaml"] - ["envs/env2.yaml"] - ["envs/env1.yaml", "envs/env2.yaml"] + expected-failure: ["false"] + include: + - env-paths: ["envs/failing-env1.yaml"] + expected-failure: "true" + - env-paths: ["envs/env1.yaml", "envs/failing-env1.yaml"] + expected-failure: "true" steps: - name: clone the repository uses: actions/checkout@v4 - name: run action uses: ./ + id: action-run + continue-on-error: true with: - environment-paths: ${{ matrix.env-paths }} + environment-paths: "${{ toJSON(matrix.env-paths) }}" + - name: detect outcome + if: always() + shell: bash -l {0} + run: | + if [[ "${{ steps.action-run.outcome }}" == "success" && ${{ matrix.expected-failure }} == "true" ]]; then + # unexpected pass + echo "workflow xpassed" + export STATUS=1 + elif [[ "${{ steps.action-run.outcome }}" == "failure" && ${{ matrix.expected-failure }} == "false" ]]; then + # unexpected failure + echo "workflow failed" + export STATUS=2 + elif [[ "${{ steps.action-run.outcome }}" == "success" && ${{ matrix.expected-failure }} == "false" ]]; then + # normal pass + echo "workflow passed" + export STATUS=0 + elif [[ "${{ steps.action-run.outcome }}" == "failure" && ${{ matrix.expected-failure }} == "true" ]]; then + # expected failure + echo "workflow xfailed" + export STATUS=0 + else + # cancelled + echo "workflow cancelled" + export STATUS=3 + fi + exit $STATUS diff --git a/action.yaml b/action.yaml index 286fb6e..f9f852a 100644 --- a/action.yaml +++ b/action.yaml @@ -6,8 +6,21 @@ inputs: description: >- The paths to the environment files required: True + type: string outputs: {} runs: using: "composite" - steps: {} + + steps: + - name: install dependencies + shell: bash -l {0} + run: | + python -m pip install -r requirements.txt + - name: analyze environments + shell: bash -l {0} + env: + COLUMNS: 120 + FORCE_COLOR: 3 + run: | + python minimum_versions.py ${{ join(fromJSON(inputs.environment-paths), ' ') }} diff --git a/envs/env1.yaml b/envs/env1.yaml new file mode 100644 index 0000000..9ab3f67 --- /dev/null +++ b/envs/env1.yaml @@ -0,0 +1,7 @@ +channels: + - conda-forge +dependencies: + - python=3.10 + - numpy=1.24 + - pandas=2.1 + - packaging=23.1 diff --git a/envs/env2.yaml b/envs/env2.yaml new file mode 100644 index 0000000..8ac6e2d --- /dev/null +++ b/envs/env2.yaml @@ -0,0 +1,8 @@ +channels: + - conda-forge +dependencies: + - python=3.10 + - numpy=1.23 + - xarray=2023.10.0 + - dask=2023.10.0 + - distributed=2023.10.0 diff --git a/envs/failing-env1.yaml b/envs/failing-env1.yaml new file mode 100644 index 0000000..2022866 --- /dev/null +++ b/envs/failing-env1.yaml @@ -0,0 +1,6 @@ +channels: + - conda-forge +dependencies: + - python=3.11 + - numpy=2.1 + - pandas=2.2.1 diff --git a/minimum_versions.py b/minimum_versions.py new file mode 100644 index 0000000..321537a --- /dev/null +++ b/minimum_versions.py @@ -0,0 +1,331 @@ +import asyncio +import bisect +import datetime +import pathlib +import sys +from dataclasses import dataclass, field + +import rich_click as click +import yaml +from dateutil.relativedelta import relativedelta +from rattler import Gateway, Version +from rich.console import Console +from rich.panel import Panel +from rich.style import Style +from rich.table import Column, Table +from tlz.functoolz import curry, pipe +from tlz.itertoolz import concat, groupby + +click.rich_click.SHOW_ARGUMENTS = True + +channels = ["conda-forge"] +platforms = ["noarch", "linux-64"] +ignored_packages = [ + "coveralls", + "pip", + "pytest", + "pytest-cov", + "pytest-env", + "pytest-xdist", + "pytest-timeout", + "hypothesis", +] + + +@dataclass +class Policy: + package_months: dict + default_months: int + overrides: dict[str, Version] = field(default_factory=dict) + + def minimum_version(self, today, package_name, releases): + if (override := self.overrides.get(package_name)) is not None: + return find_release(releases, version=override) + + suitable_releases = [ + release for release in releases if is_suitable_release(release) + ] + + policy_months = self.package_months.get(package_name, self.default_months) + + cutoff_date = today - relativedelta(months=policy_months) + + index = bisect.bisect_left( + suitable_releases, cutoff_date, key=lambda x: x.timestamp.date() + ) + return suitable_releases[index - 1 if index > 0 else 0] + + +@dataclass +class Spec: + name: str + version: Version | None + + @classmethod + def parse(cls, spec_text): + warnings = [] + if ">" in spec_text or "<" in spec_text: + warnings.append( + f"package must be pinned with an exact version: {spec_text!r}. Using the version as an exact pin instead." + ) + + spec_text = spec_text.replace(">", "").replace("<", "") + + if "=" in spec_text: + name, version_text = spec_text.split("=", maxsplit=1) + version = Version(version_text) + segments = version.segments() + + if (len(segments) == 3 and segments[2] != [0]) or len(segments) > 3: + warnings.append( + f"package should be pinned to a minor version (got {version})" + ) + else: + name = spec_text + version = None + + return cls(name, version), (name, warnings) + + +@dataclass(order=True) +class Release: + version: Version + build_number: int + timestamp: datetime.datetime = field(compare=False) + + @classmethod + def from_repodata_record(cls, repo_data): + return cls( + version=repo_data.version, + build_number=repo_data.build_number, + timestamp=repo_data.timestamp, + ) + + +def parse_environment(text): + env = yaml.safe_load(text) + + specs = [] + warnings = [] + for dep in env["dependencies"]: + spec, warnings_ = Spec.parse(dep) + + specs.append(spec) + warnings.append(warnings_) + + return specs, warnings + + +def is_preview(version): + candidates = {"rc", "b", "a"} + + *_, last_segment = version.segments() + return any(candidate in last_segment for candidate in candidates) + + +def group_packages(records): + groups = groupby(lambda r: r.name.normalized, records) + return { + name: sorted(map(Release.from_repodata_record, group)) + for name, group in groups.items() + } + + +def filter_releases(predicate, releases): + return { + name: [r for r in records if predicate(r)] for name, records in releases.items() + } + + +def find_release(releases, version): + index = bisect.bisect_left(releases, version, key=lambda x: x.version) + return releases[index] + + +def deduplicate_releases(package_info): + def deduplicate(releases): + return min(releases, key=lambda p: p.timestamp) + + return { + name: list(map(deduplicate, groupby(lambda p: p.version, group).values())) + for name, group in package_info.items() + } + + +def find_policy_versions(policy, today, releases): + return { + name: policy.minimum_version(today, name, package_releases) + for name, package_releases in releases.items() + } + + +def is_suitable_release(release): + if release.timestamp is None: + return False + + segments = release.version.extend_to_length(3).segments() + + return segments[2] == [0] + + +def lookup_spec_release(spec, releases): + version = spec.version.extend_to_length(3) + + return releases[spec.name][version] + + +def compare_versions(environments, policy_versions): + status = {} + for env, specs in environments.items(): + env_status = any( + spec.version > policy_versions[spec.name].version for spec in specs + ) + status[env] = env_status + return status + + +def version_comparison_symbol(required, policy): + if required < policy: + return "<" + elif required > policy: + return ">" + else: + return "=" + + +def format_bump_table(specs, policy_versions, releases, warnings): + table = Table( + Column("Package", width=20), + Column("Required", width=8), + "Required (date)", + Column("Policy", width=8), + "Policy (date)", + "Status", + ) + + heading_style = Style(color="#ff0000", bold=True) + warning_style = Style(color="#ffff00", bold=True) + styles = { + ">": Style(color="#ff0000", bold=True), + "=": Style(color="#008700", bold=True), + "<": Style(color="#d78700", bold=True), + } + + for spec in specs: + policy_release = policy_versions[spec.name] + policy_version = policy_release.version.with_segments(0, 2) + policy_date = policy_release.timestamp + + required_version = spec.version + required_date = lookup_spec_release(spec, releases).timestamp + + status = version_comparison_symbol(required_version, policy_version) + style = styles[status] + + table.add_row( + spec.name, + str(required_version), + f"{required_date:%Y-%m-%d}", + str(policy_version), + f"{policy_date:%Y-%m-%d}", + status, + style=style, + ) + + grid = Table.grid(expand=True, padding=(0, 2)) + grid.add_column(style=heading_style, vertical="middle") + grid.add_column() + grid.add_row("Version summary", table) + + if any(warnings.values()): + warning_table = Table(width=table.width, expand=True) + warning_table.add_column("Package") + warning_table.add_column("Warning") + + for package, messages in warnings.items(): + if not messages: + continue + warning_table.add_row(package, messages[0], style=warning_style) + for message in messages[1:]: + warning_table.add_row("", message, style=warning_style) + + grid.add_row("Warnings", warning_table) + + return grid + + +@click.command() +@click.argument( + "environment_paths", + type=click.Path(exists=True, readable=True, path_type=pathlib.Path), + nargs=-1, +) +def main(environment_paths): + console = Console() + + parsed_environments = { + path.stem: parse_environment(path.read_text()) for path in environment_paths + } + + warnings = { + env: dict(warnings_) for env, (_, warnings_) in parsed_environments.items() + } + environments = { + env: [spec for spec in specs if spec.name not in ignored_packages] + for env, (specs, _) in parsed_environments.items() + } + + all_packages = list( + dict.fromkeys(spec.name for spec in concat(environments.values())) + ) + + policy_months = { + "python": 30, + "numpy": 18, + } + policy_months_default = 12 + overrides = {} + + policy = Policy( + policy_months, default_months=policy_months_default, overrides=overrides + ) + + gateway = Gateway() + query = gateway.query(channels, platforms, all_packages, recursive=False) + records = asyncio.run(query) + + today = datetime.date.today() + package_releases = pipe( + records, + concat, + group_packages, + curry(filter_releases, lambda r: r.timestamp is not None), + deduplicate_releases, + ) + policy_versions = pipe( + package_releases, + curry(find_policy_versions, policy, today), + ) + status = compare_versions(environments, policy_versions) + + release_lookup = { + n: {r.version: r for r in releases} for n, releases in package_releases.items() + } + grids = { + env: format_bump_table(specs, policy_versions, release_lookup, warnings[env]) + for env, specs in environments.items() + } + root_grid = Table.grid() + root_grid.add_column() + + for env, grid in grids.items(): + root_grid.add_row(Panel(grid, title=env, expand=True)) + + console.print(root_grid) + + status_code = 1 if any(status.values()) else 0 + sys.exit(status_code) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3a72cd4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +py-rattler +rich +rich-click +cytoolz +pyyaml +python-dateutil diff --git a/test_script.py b/test_script.py new file mode 100644 index 0000000..32d4db3 --- /dev/null +++ b/test_script.py @@ -0,0 +1,74 @@ +import datetime as dt + +import pytest +from rattler import Version + +from minimum_versions import Policy, Release, Spec + + +@pytest.mark.parametrize( + ["text", "expected_spec", "expected_name", "expected_warnings"], + ( + ("numpy=1.23", Spec("numpy", Version("1.23")), "numpy", []), + ("xarray=2024.10.0", Spec("xarray", Version("2024.10.0")), "xarray", []), + ( + "xarray=2024.10.1", + Spec("xarray", Version("2024.10.1")), + "xarray", + ["package should be pinned to a minor version (got 2024.10.1)"], + ), + ), +) +def test_spec_parse(text, expected_spec, expected_name, expected_warnings): + actual_spec, (actual_name, actual_warnings) = Spec.parse(text) + + assert actual_spec == expected_spec + assert actual_name == expected_name + assert actual_warnings == expected_warnings + + +@pytest.mark.parametrize( + ["package_name", "policy", "today", "expected"], + ( + ( + "numpy", + Policy({"numpy": 6}, 12, {}), + dt.date(2023, 12, 12), + Release(Version("1.23.0"), 0, dt.datetime(2023, 6, 9)), + ), + ( + "scipy", + Policy({"numpy": 6}, 8, {}), + dt.date(2024, 9, 5), + Release(Version("1.2.0"), 0, dt.datetime(2024, 1, 3)), + ), + ( + "scipy", + Policy({"numpy": 6}, 8, {"scipy": Version("1.1.1")}), + dt.date(2024, 9, 5), + Release(Version("1.1.1"), 0, dt.datetime(2023, 12, 1)), + ), + ), +) +def test_policy_minimum_version(package_name, policy, today, expected): + releases = { + "numpy": [ + Release(Version("1.22.0"), 0, dt.datetime(2022, 12, 1)), + Release(Version("1.22.1"), 0, dt.datetime(2023, 2, 5)), + Release(Version("1.23.0"), 0, dt.datetime(2023, 6, 9)), + Release(Version("1.23.1"), 0, dt.datetime(2023, 8, 12)), + Release(Version("1.23.2"), 0, dt.datetime(2023, 12, 5)), + ], + "scipy": [ + Release(Version("1.0.0"), 0, dt.datetime(2022, 11, 10)), + Release(Version("1.0.1"), 0, dt.datetime(2023, 1, 13)), + Release(Version("1.1.0"), 0, dt.datetime(2023, 9, 21)), + Release(Version("1.1.1"), 0, dt.datetime(2023, 12, 1)), + Release(Version("1.2.0"), 0, dt.datetime(2024, 1, 3)), + Release(Version("1.2.1"), 0, dt.datetime(2024, 2, 5)), + ], + } + + actual = policy.minimum_version(today, package_name, releases[package_name]) + + assert actual == expected