diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0be3c0f..42a5162 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] steps: - name: clone the repository @@ -31,6 +31,7 @@ jobs: - name: install dependencies run: | python -m pip install -r requirements.txt + python -m pip install . python -m pip install pytest - name: run tests run: | @@ -43,7 +44,7 @@ jobs: strategy: fail-fast: false matrix: - env-paths: + envs: - "envs/env1.yaml" - "envs/env2.yaml" - | @@ -52,18 +53,38 @@ jobs: expected-failure: ["false"] policy-file: ["policy.yaml"] include: - - env-paths: | + - envs: | envs/failing-env1.yaml policy-file: "policy.yaml" expected-failure: "true" - - env-paths: | + - envs: | envs/env1.yaml envs/failing-env1.yaml policy-file: "policy.yaml" expected-failure: "true" - - env-paths: "envs/env1.yaml" - policy-file: policy_no_extra_options.yaml + - envs: "envs/env1.yaml" + policy-file: "policy_no_extra_options.yaml" expected-failure: "false" + - envs: "pixi:env1" + manifest-path: "envs/pixi.toml" + policy-file: "policy.yaml" + expected-failure: "false" + - envs: | + pixi:env1 + pixi:env2 + manifest-path: "envs/pixi.toml" + policy-file: "policy.yaml" + expected-failure: "false" + - envs: | + pixi:env1 + conda:envs/env2.yaml + manifest-path: "envs/pixi.toml" + policy-file: "policy.yaml" + expected-failure: "false" + - envs: "pixi:failing-env" + manifest-path: "envs/pixi.toml" + policy-file: "policy.yaml" + expected-failure: "true" steps: - name: clone the repository @@ -74,8 +95,9 @@ jobs: continue-on-error: true with: policy: ${{ matrix.policy-file }} - environment-paths: ${{ matrix.env-paths }} + environments: ${{ matrix.envs }} today: 2024-12-20 + manifest-path: ${{ matrix.manifest-path }} - name: detect outcome if: always() shell: bash -l {0} diff --git a/README.md b/README.md index 62b3fba..a11fa60 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,11 @@ them to an empty mapping or sequence, respectively: ignored_violations: [] ``` -Then add a new step to CI: +Then add a new step to CI. + +### conda + +To analyze conda environments, simply pass the path to the environment file (`env.yaml`) to the `environments` key. ```yaml jobs: @@ -53,7 +57,7 @@ jobs: - uses: xarray-contrib/minimum-dependency-versions@version with: policy: policy.yaml - environment-paths: path/to/env.yaml + environments: path/to/env.yaml ``` To analyze multiple environments at the same time, pass a multi-line string: @@ -67,8 +71,61 @@ jobs: - uses: xarray-contrib/minimum-dependency-versions@version with: - environment-paths: | + environments: | path/to/env1.yaml path/to/env2.yaml - path/to/env3.yaml + conda:path/to/env3.yaml # the conda: prefix is optional +``` + +### pixi + +To analyze pixi environments, specify the environment name prefixed with `pixi:` and point to the manifest file using `manifest-path`: + +```yaml +jobs: + my-job: + ... + steps: + ... + + - uses: xarray-contrib/minimum-dependency-versions@version + with: + environments: pixi:env1 + manifest-path: /path/to/pixi.toml # or pyproject.toml +``` + +Multiple environments can be analyzed at the same time: + +```yaml +jobs: + my-job: + ... + steps: + ... + + - uses: xarray-contrib/minimum-dependency-versions@version + with: + environments: | + pixi:env1 + pixi:env2 + manifest-path: /path/to/pixi.toml # or pyproject.toml +``` + +### Mixing environment types + +It is even possible to mix environment types (once again, the `conda:` prefix is optional but recommended): + +```yaml +jobs: + my-job: + ... + steps: + ... + + - uses: xarray-contrib/minimum-dependency-versions@version + with: + environments: | + pixi:env1 + conda:path/to/env.yaml + manifest-path: path/to/pixi.toml # or pyproject.toml ``` diff --git a/action.yaml b/action.yaml index 82a9fdc..cf3e5bf 100644 --- a/action.yaml +++ b/action.yaml @@ -7,18 +7,24 @@ inputs: The path to the policy to follow required: true type: string - environment-paths: + today: + description: >- + Time machine for testing + required: false + type: string + environments: description: >- - The paths to the environment files + The names or paths of the environments. Pixi environment names must be + prefixed with `pixi:`. Conda environment paths may be prefixed with + `conda:`. If there is no prefix, it is assumed to be a conda env path. required: true type: list - today: + manifest-path: description: >- - Time machine for testing + Path to the manifest file of `pixi`. Required for `pixi` environments. required: false type: string outputs: {} - runs: using: "composite" @@ -28,6 +34,7 @@ runs: run: | echo "::group::Install dependencies" python -m pip install -r ${{ github.action_path }}/requirements.txt + python -m pip install ${{ github.action_path }} echo "::endgroup::" - name: analyze environments shell: bash -l {0} @@ -35,7 +42,12 @@ runs: COLUMNS: 120 FORCE_COLOR: 3 POLICY_PATH: ${{ inputs.policy }} - ENVIRONMENT_PATHS: ${{ inputs.environment-paths }} + ENVIRONMENTS: ${{ inputs.environments }} TODAY: ${{ inputs.today }} + MANIFEST_PATH: ${{ inputs.manifest-path }} run: | - PYTHONPATH=${{github.action_path}} python -m minimum_versions validate --today="$TODAY" --policy="$POLICY_PATH" $ENVIRONMENT_PATHS + python -m minimum_versions validate \ + --today="$TODAY" \ + --policy="$POLICY_PATH" \ + --manifest-path="$MANIFEST_PATH" \ + $ENVIRONMENTS diff --git a/envs/pixi.toml b/envs/pixi.toml new file mode 100644 index 0000000..365e095 --- /dev/null +++ b/envs/pixi.toml @@ -0,0 +1,17 @@ +[dependencies] +pandas = "2.1" +packaging = "23.1" + +[feature.py39.dependencies] +python = "3.9" + +[feature.py310.dependencies] +python = "3.10" + +[feature.failing.dependencies] +numpy = "2.1" + +[environments] +env1 = { features = ["py310"] } +env2 = ["py39"] +failing-env = { features = ["failing"] } diff --git a/minimum_versions/environments/__init__.py b/minimum_versions/environments/__init__.py new file mode 100644 index 0000000..a4df9d1 --- /dev/null +++ b/minimum_versions/environments/__init__.py @@ -0,0 +1,25 @@ +import pathlib + +from minimum_versions.environments.conda import parse_conda_environment +from minimum_versions.environments.pixi import parse_pixi_environment +from minimum_versions.environments.spec import Spec, compare_versions # noqa: F401 + +kinds = { + "conda": parse_conda_environment, + "pixi": parse_pixi_environment, +} + + +def parse_environment(specifier: str, manifest_path: pathlib.Path | None) -> list[Spec]: + split = specifier.split(":", maxsplit=1) + if len(split) == 1: + kind = "conda" + path = specifier + else: + kind, path = split + + parser = kinds.get(kind) + if parser is None: + raise ValueError(f"Unknown kind {kind!r}, extracted from {specifier!r}.") + + return parser(path, manifest_path) diff --git a/minimum_versions/environments/conda.py b/minimum_versions/environments/conda.py new file mode 100644 index 0000000..27dcfc0 --- /dev/null +++ b/minimum_versions/environments/conda.py @@ -0,0 +1,46 @@ +import pathlib + +import yaml +from rattler import Version + +from minimum_versions.environments.spec import Spec + + +def parse_spec(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 Spec(name, version), (name, warnings) + + +def parse_conda_environment(path: pathlib.Path, manifest_path: None): + env = yaml.safe_load(pathlib.Path(path).read_text()) + + specs = [] + warnings = [] + for dep in env["dependencies"]: + spec, warnings_ = parse_spec(dep) + + specs.append(spec) + warnings.append(warnings_) + + return specs, warnings diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py new file mode 100644 index 0000000..60e634a --- /dev/null +++ b/minimum_versions/environments/pixi.py @@ -0,0 +1,152 @@ +import pathlib +import re + +import tomllib +from rattler import Version +from tlz.dicttoolz import get_in, merge + +from minimum_versions.environments.spec import Spec + +_version_re = r"[0-9]+\.[0-9]+(?:\.[0-9]+|\.\*)?" +version_re = re.compile(f"(?P{_version_re})") +lower_pin_re = re.compile(rf">=(?P{_version_re})$") +tight_pin_re = re.compile(rf">=(?P{_version_re}),<(?P{_version_re})") + + +def parse_spec(name, version_text: str | dict): + # "*" => None + # "x.y.*" => "x.y" + # ">=x.y.0, "x.y" (+ warning) + # ">=x.y.*" => "x.y" (+ warning) + + if isinstance(version_text, dict): + version_text = version_text.get("version", "*") + + warnings = [] + if version_text == "*": + raw_version = None + elif (match := version_re.match(version_text)) is not None: + raw_version = match.group("version") + elif (match := lower_pin_re.match(version_text)) is not None: + warnings.append( + f"package must be pinned with an exact version: {version_text!r}." + " Using the version as an exact pin instead." + ) + + raw_version = match.group("version") + elif (match := tight_pin_re.match(version_text)) is not None: + lower_pin = match.group("lower") + upper_pin = match.group("upper") + + warnings.append( + f"lower pin {lower_pin!r} and upper pin {upper_pin!r} found." + " Using the lower pin for now, please convert to the standard x.y.* syntax." + ) + + raw_version = lower_pin + else: + raise ValueError(f"Unsupported version spec: {version_text}") + + if raw_version is not None: + version = Version(raw_version.removesuffix(".*")) + 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: + version = raw_version + + return Spec(name, version), (name, warnings) + + +def parse_pixi_environment(name: str, manifest_path: pathlib.Path | None): + if manifest_path is None: + raise ValueError("--manifest-path is required for pixi environments.") + + with manifest_path.open(mode="rb") as f: + data = tomllib.load(f) + + if manifest_path.name == "pyproject.toml": + pixi_config = get_in(["tool", "pixi"], data, None) + if pixi_config is None: + raise ValueError( + f"The 'tool.pixi' section is missing from {manifest_path}." + ) + else: + pixi_config = data + + environment_definitions = pixi_config.get("environments") + if environment_definitions is None: + raise ValueError("Can't find environments in the pixi config.") + + all_features = pixi_config.get("feature", {}) + + env = environment_definitions.get(name) + if env is None: + raise ValueError(f"Unknown environment: {name}") + + if isinstance(env, list): + feature_names = env + elif isinstance(env, dict) and env.keys() - {"features", "no-default-feature"}: + raise ValueError( + "Options other than 'features' and 'no-default-feature'" + f" are not supported. Got {env}." + ) + elif isinstance(env, dict): + feature_names = env["features"] + if not env.get("no-default-feature", False): + feature_names.insert(0, "default") + else: + raise ValueError("unexpected environment type") + + unknown_features = [ + name for name in feature_names if name != "default" and name not in all_features + ] + if unknown_features: + raise ValueError(f"unknown features: {', '.join(unknown_features)}") + + features = [ + ( + get_in([feature, "dependencies"], all_features, {}) + if feature != "default" + else pixi_config.get("dependencies", []) + ) + for feature in feature_names + ] + + local_package_name = get_in(["package", "name"], pixi_config, None) + pins = { + name: pin + for name, pin in merge(features).items() + # skip the local package, if any + if name != local_package_name + } + + specs = [] + warnings = [] + + pypi_dependencies = { + feature: ( + get_in([feature, "pypi-dependencies"], all_features) + if feature != "default" + else pixi_config.get("pypi-dependencies", []) + ) + for feature in feature_names + } + with_pypi_dependencies = { + feature: bool(deps) for feature, deps in pypi_dependencies.items() if deps + } + for feature in with_pypi_dependencies: + warnings.append((f"feature:{feature}", ["Ignored PyPI dependencies."])) + for package_name, pin in pins.items(): + try: + spec, warnings_ = parse_spec(package_name, pin) + except ValueError as e: + e.add_note(f"environment {name}: {package_name}{pin}") + raise + + specs.append(spec) + warnings.append(warnings_) + + return specs, warnings diff --git a/minimum_versions/environments/spec.py b/minimum_versions/environments/spec.py new file mode 100644 index 0000000..f93fa94 --- /dev/null +++ b/minimum_versions/environments/spec.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + +from rattler import Version + + +@dataclass +class Spec: + name: str + version: Version | None + + +def compare_versions(environments, policy_versions, ignored_violations): + status = {} + for env, specs in environments.items(): + env_status = any( + ( + spec.name not in ignored_violations + and ( + spec.version is None + or spec.version > policy_versions[spec.name].version + ) + ) + for spec in specs + ) + status[env] = env_status + return status diff --git a/minimum_versions/formatting.py b/minimum_versions/formatting.py index 7c9c879..ba6fca9 100644 --- a/minimum_versions/formatting.py +++ b/minimum_versions/formatting.py @@ -21,7 +21,9 @@ def lookup_spec_release(spec, releases): def version_comparison_symbol(required, policy): - if required < policy: + if required is None: + return "!" + elif required < policy: return "<" elif required > policy: return ">" @@ -45,6 +47,7 @@ def format_bump_table(specs, policy_versions, releases, warnings, ignored_violat ">": Style(color="#ff0000", bold=True), "=": Style(color="#008700", bold=True), "<": Style(color="#d78700", bold=True), + "!": warning_style, } for spec in specs: @@ -53,7 +56,13 @@ def format_bump_table(specs, policy_versions, releases, warnings, ignored_violat policy_date = policy_release.timestamp required_version = spec.version - required_date = lookup_spec_release(spec, releases).timestamp + if required_version is None: + warnings[spec.name].append( + "Unpinned dependency. Consider pinning or ignoring this dependency." + ) + required_date = None + else: + required_date = lookup_spec_release(spec, releases).timestamp status = version_comparison_symbol(required_version, policy_version) if status == ">" and spec.name in ignored_violations: @@ -63,8 +72,8 @@ def format_bump_table(specs, policy_versions, releases, warnings, ignored_violat table.add_row( spec.name, - str(required_version), - f"{required_date:%Y-%m-%d}", + str(required_version) if required_version is not None else "", + f"{required_date:%Y-%m-%d}" if required_date is not None else "", str(policy_version), f"{policy_date:%Y-%m-%d}", status, diff --git a/minimum_versions/main.py b/minimum_versions/main.py index 0f3ffc0..4857c2a 100644 --- a/minimum_versions/main.py +++ b/minimum_versions/main.py @@ -1,17 +1,19 @@ import datetime +import os.path import pathlib import sys +from typing import Any import rich_click as click from rich.console import Console from rich.panel import Panel from rich.table import Table -from tlz.itertoolz import concat +from tlz.itertoolz import concat, unique +from minimum_versions.environments import compare_versions, parse_environment from minimum_versions.formatting import format_bump_table from minimum_versions.policy import find_policy_versions, parse_policy from minimum_versions.release import fetch_releases -from minimum_versions.spec import compare_versions, parse_environment click.rich_click.SHOW_ARGUMENTS = True @@ -23,26 +25,39 @@ def parse_date(string): return datetime.datetime.strptime(string, "%Y-%m-%d").date() +class _Path(click.Path): + def convert( + self, value: Any, param: click.Parameter | None, ctx: click.Context | None + ) -> Any: + if not value: + return None + + return super().convert(value, param, ctx) + + @click.group() def main(): pass @main.command() -@click.argument( - "environment_paths", - type=click.Path(exists=True, readable=True, path_type=pathlib.Path), - nargs=-1, +@click.argument("environment_paths", type=str, nargs=-1) +@click.option( + "--manifest-path", + "manifest_path", + type=_Path(exists=True, path_type=pathlib.Path), + default=None, ) @click.option("--today", type=parse_date, default=None) @click.option("--policy", "policy_file", type=click.File(mode="r"), required=True) -def validate(today, policy_file, environment_paths): +def validate(today, policy_file, manifest_path, environment_paths): console = Console() policy = parse_policy(policy_file) parsed_environments = { - path.stem: parse_environment(path.read_text()) for path in environment_paths + path.rsplit(os.path.sep, maxsplit=1)[-1]: parse_environment(path, manifest_path) + for path in environment_paths } warnings = { @@ -54,7 +69,11 @@ def validate(today, policy_file, environment_paths): } all_packages = list( - dict.fromkeys(spec.name for spec in concat(environments.values())) + unique( + spec.name + for spec in concat(environments.values()) + if spec.name not in policy.exclude + ) ) package_releases = fetch_releases(policy.channels, policy.platforms, all_packages) diff --git a/minimum_versions/policy.py b/minimum_versions/policy.py index 3d25bf2..a167c04 100644 --- a/minimum_versions/policy.py +++ b/minimum_versions/policy.py @@ -82,6 +82,8 @@ def minimum_version(self, today, package_name, releases): suitable_releases = [ release for release in releases if is_suitable_release(release) ] + if not suitable_releases: + raise ValueError(f"Cannot find valid releases for {package_name}") policy_months = self.package_months.get(package_name, self.default_months) @@ -90,6 +92,7 @@ def minimum_version(self, today, package_name, releases): index = bisect.bisect_left( suitable_releases, cutoff_date, key=lambda x: x.timestamp.date() ) + return suitable_releases[index - 1 if index > 0 else 0] diff --git a/minimum_versions/spec.py b/minimum_versions/spec.py deleted file mode 100644 index a7c72d5..0000000 --- a/minimum_versions/spec.py +++ /dev/null @@ -1,63 +0,0 @@ -from dataclasses import dataclass - -import yaml -from rattler import Version - - -@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) - - -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 compare_versions(environments, policy_versions, ignored_violations): - status = {} - for env, specs in environments.items(): - env_status = any( - ( - spec.name not in ignored_violations - and spec.version > policy_versions[spec.name].version - ) - for spec in specs - ) - status[env] = env_status - return status diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py new file mode 100644 index 0000000..1592d31 --- /dev/null +++ b/minimum_versions/tests/test_environments.py @@ -0,0 +1,392 @@ +import io +import pathlib +import textwrap +from dataclasses import dataclass + +import pytest +from rattler import Version + +from minimum_versions import environments +from minimum_versions.environments.spec import Spec + + +@dataclass +class FakeRecord: + version: Version | None + + +@pytest.mark.parametrize("manifest_path", ("a/pixi.toml", "b/pyproject.toml", None)) +@pytest.mark.parametrize( + ["specifier", "key"], (("conda:ci/environment.yml", "conda"), ("pixi:env", "pixi")) +) +def test_parse_environment(specifier, manifest_path, key, monkeypatch): + results = {"conda": object(), "pixi": object()} + kinds = { + "conda": lambda s, m: results["conda"], + "pixi": lambda s, m: results["pixi"], + } + monkeypatch.setattr(environments, "kinds", kinds) + + actual = environments.parse_environment(specifier, manifest_path) + expected = results[key] + + assert actual is expected + + +@pytest.mark.parametrize( + ["envs", "ignored_violations", "expected"], + ( + pytest.param( + { + "env1": [ + Spec("a", Version("1.2")), + Spec("c", Version("2024.8")), + Spec("d", Version("0.6")), + ] + }, + ["d"], + {"env1": False}, + id="single-violation-ignored", + ), + pytest.param( + { + "env1": [Spec("b", Version("3.2")), Spec("c", Version("2025.2"))], + "env2": [Spec("b", Version("3.1"))], + }, + [], + {"env1": True, "env2": False}, + id="multiple-split-not ignored", + ), + pytest.param( + {"env1": [Spec("d", None)]}, + [], + {"env1": True}, + id="single-none-not ignored", + ), + pytest.param( + {"env1": [Spec("d", None)]}, + ["d"], + {"env1": False}, + id="single-none-ignored", + ), + ), +) +def test_compare_versions(envs, ignored_violations, expected): + policy_versions = { + "a": FakeRecord(version=Version("1.2")), + "b": FakeRecord(version=Version("3.1")), + "c": FakeRecord(version=Version("2025.1")), + "d": FakeRecord(version=Version("0.5")), + } + + actual = environments.spec.compare_versions( + envs, policy_versions, ignored_violations + ) + assert actual == expected + + +class TestCondaEnvironment: + @pytest.mark.parametrize( + ["spec_text", "expected_spec", "expected_warnings"], + ( + pytest.param( + "a=3.2", Spec("a", Version("3.2")), [], id="exact-no_warnings" + ), + pytest.param( + "b>=1.1", + Spec("b", Version("1.1")), + [ + "package must be pinned with an exact version: 'b>=1.1'." + " Using the version as an exact pin instead." + ], + id="lower_bound", + ), + pytest.param( + "b<=4.1", + Spec("b", Version("4.1")), + [ + "package must be pinned with an exact version: 'b<=4.1'." + " Using the version as an exact pin instead." + ], + id="upper_equal_bound", + ), + pytest.param( + "b<4.1", + Spec("b", Version("4.1")), + [ + "package must be pinned with an exact version: 'b<=4.1'." + " Using the version as an exact pin instead." + ], + marks=pytest.mark.xfail( + reason="exclusive upper bounds are not supported" + ), + id="upper_bound", + ), + pytest.param( + "b>4.1", + Spec("b", Version("4.1")), + [ + "package must be pinned with an exact version: 'b>4.1'." + " Using the version as an exact pin instead." + ], + marks=pytest.mark.xfail( + reason="exclusive lower bounds are not supported" + ), + id="lower_bound", + ), + pytest.param( + "c=1.6.2", + Spec("c", Version("1.6.2")), + ["package should be pinned to a minor version (got 1.6.2)"], + ), + ), + ) + def test_parse_spec(self, spec_text, expected_spec, expected_warnings): + actual_spec, (actual_name, actual_warnings) = environments.conda.parse_spec( + spec_text + ) + + assert actual_spec == expected_spec + assert actual_name == expected_spec.name + assert actual_warnings == expected_warnings + + def test_parse_environment(self, monkeypatch): + data = textwrap.dedent( + """\ + channels: + - conda-forge + dependencies: + - a=1.1 + - b>=3.2 + - c=1.6.5 + """.rstrip() + ) + monkeypatch.setattr(pathlib.Path, "read_text", lambda _: data) + + expected_specs = [ + Spec("a", Version("1.1")), + Spec("b", Version("3.2")), + Spec("c", Version("1.6.5")), + ] + expected_warnings = [ + ("a", []), + ( + "b", + [ + "package must be pinned with an exact version: 'b>=3.2'." + " Using the version as an exact pin instead." + ], + ), + ("c", ["package should be pinned to a minor version (got 1.6.5)"]), + ] + + actual_specs, actual_warnings = environments.conda.parse_conda_environment( + "env1.yaml", None + ) + + assert actual_specs == expected_specs + assert actual_warnings == expected_warnings + + +class TestPixiEnvironment: + @pytest.mark.parametrize( + ["name", "version_text", "expected_spec", "expected_warnings"], + ( + pytest.param( + "a", "1.2.*", Spec("a", Version("1.2")), [], id="star_pin–no_warnings" + ), + pytest.param( + "b", + ">=3.1", + Spec("b", Version("3.1")), + [ + "package must be pinned with an exact version: '>=3.1'." + " Using the version as an exact pin instead." + ], + id="lower_pin", + ), + pytest.param( + "c", + ">=1.6.0,<1.7.0", + Spec("c", Version("1.6")), + [ + "lower pin '1.6.0' and upper pin '1.7.0' found." + " Using the lower pin for now, please convert to" + " the standard x.y.* syntax." + ], + id="tight_pin", + ), + pytest.param( + "d", + "1.9.1", + Spec("d", Version("1.9.1")), + ["package should be pinned to a minor version (got 1.9.1)"], + id="patch_pin", + ), + pytest.param("e", "*", Spec("e", None), [], id="unpinned"), + pytest.param("f", {"path": "."}, Spec("f", None), [], id="source_package"), + ), + ) + def test_parse_spec(self, name, version_text, expected_spec, expected_warnings): + actual_spec, (actual_name, actual_warnings) = environments.pixi.parse_spec( + name, version_text + ) + + assert actual_spec == expected_spec + assert actual_name == name + assert actual_warnings == expected_warnings + + @pytest.mark.parametrize("version_text", ("~1.3", "^2.1", "<1.1", "<=2025.01")) + def test_parse_spec_error(self, version_text): + with pytest.raises(ValueError, match="Unsupported version spec: .*"): + environments.pixi.parse_spec("package", version_text) + + @pytest.mark.parametrize( + ["data", "path", "expected_specs", "expected_warnings"], + ( + pytest.param( + textwrap.dedent( + """\ + [dependencies] + a = "1.0.*" + b = "2.2.*" + + [feature.feature1.dependencies] + c = "3.1.*" + + [environments] + env1 = { features = ["feature1"] } + """.rstrip() + ), + "pixi.toml", + [ + Spec("a", Version("1.0")), + Spec("b", Version("2.2")), + Spec("c", Version("3.1")), + ], + [("a", []), ("b", []), ("c", [])], + id="default-feature", + ), + pytest.param( + textwrap.dedent( + """\ + [dependencies] + a = "1.0.*" + b = "2.2.*" + + [feature.feature1.dependencies] + c = "3.1.*" + + [environments] + env1 = { features = ["feature1"], no-default-feature = true } + """.rstrip() + ), + "pixi.toml", + [Spec("c", Version("3.1"))], + [("c", [])], + id="no-default-feature", + ), + pytest.param( + textwrap.dedent( + """\ + [dependencies] + a = "1.0.*" + + [environments] + env1 = { features = [] } + """.rstrip() + ), + "pixi.toml", + [Spec("a", Version("1.0"))], + [("a", [])], + id="missing-features", + ), + pytest.param( + textwrap.dedent( + """\ + [dependencies] + a = "1.0.*" + + [pypi-dependencies] + b = "3.2.*" + + [environments] + env1 = { features = [] } + """.rstrip() + ), + "pixi.toml", + [Spec("a", Version("1.0"))], + [("feature:default", ["Ignored PyPI dependencies."]), ("a", [])], + id="pypi_dependencies-default", + ), + pytest.param( + textwrap.dedent( + """\ + [dependencies] + a = "1.0.*" + + [feature.feat1.pypi-dependencies] + b = "3.2.*" + + [environments] + env1 = { features = ["feat1"] } + """.rstrip() + ), + "pixi.toml", + [Spec("a", Version("1.0"))], + [("feature:feat1", ["Ignored PyPI dependencies."]), ("a", [])], + id="pypi_dependencies-feat1", + ), + pytest.param( + textwrap.dedent( + """\ + [tool.pixi.feature.feature1.dependencies] + c = "3.1.*" + + [tool.pixi.environments] + env1 = { features = ["feature1"], no-default-feature = true } + """.rstrip() + ), + "pyproject.toml", + [Spec("c", Version("3.1"))], + [("c", [])], + id="pyproject", + ), + pytest.param( + textwrap.dedent( + """\ + [package] + name = "a" + + [dependencies] + a = { path = "." } + + [feature.feature1.dependencies] + c = "3.1.*" + + [environments] + env1 = { features = ["feature1"] } + """.rstrip() + ), + "pixi.toml", + [Spec("c", Version("3.1"))], + [("c", [])], + id="local_package", + ), + ), + ) + def test_parse_pixi_environment( + self, monkeypatch, path, data, expected_specs, expected_warnings + ): + monkeypatch.setattr( + pathlib.Path, "open", lambda _, mode: io.BytesIO(data.encode()) + ) + + name = "env1" + manifest_path = pathlib.Path(path) + + actual_specs, actual_warnings = environments.pixi.parse_pixi_environment( + name, manifest_path + ) + assert actual_specs == expected_specs + assert actual_warnings == expected_warnings diff --git a/minimum_versions/tests/test_release.py b/minimum_versions/tests/test_release.py new file mode 100644 index 0000000..875a59e --- /dev/null +++ b/minimum_versions/tests/test_release.py @@ -0,0 +1,112 @@ +import datetime as dt +from dataclasses import dataclass + +import pytest +from rattler import PackageName, Version + +from minimum_versions import release + + +@dataclass +class FakePackageRecord: + name: PackageName + version: Version + build_number: int + timestamp: dt.datetime + + +@pytest.fixture +def timestamps(): + yield [ + dt.datetime(2025, 12, 2, 20, 24, 40), + dt.datetime(2025, 12, 2, 20, 25, 40), + dt.datetime(2025, 12, 2, 20, 20, 40), + ] + + +@pytest.fixture +def records(timestamps): + yield [ + FakePackageRecord( + name=PackageName("test1"), + version=Version("1.0.0"), + build_number=1, + timestamp=timestamps[0], + ), + FakePackageRecord( + name=PackageName("test1"), + version=Version("1.0.1"), + build_number=0, + timestamp=None, + ), + FakePackageRecord( + name=PackageName("test2"), + version=Version("1.0.0"), + build_number=0, + timestamp=timestamps[2], + ), + ] + + +@pytest.fixture +def releases(timestamps): + yield { + "test1": [ + release.Release( + version=Version("1.0.0"), build_number=1, timestamp=timestamps[0] + ), + release.Release(version=Version("1.0.1"), build_number=0, timestamp=None), + ], + "test2": [ + release.Release( + version=Version("1.0.0"), build_number=0, timestamp=timestamps[2] + ) + ], + } + + +def test_release_from_repodata_record(): + repo_data = FakePackageRecord( + name=PackageName("test"), + version=Version("1.0.1"), + build_number=0, + timestamp=dt.datetime(2025, 12, 2, 20, 24, 40), + ) + + actual = release.Release.from_repodata_record(repo_data) + + assert actual.version == repo_data.version + assert actual.build_number == repo_data.build_number + assert actual.timestamp == repo_data.timestamp + + +def test_group_packages(records, releases): + actual = release.group_packages(records) + expected = releases + + assert actual == expected + + +@pytest.mark.parametrize( + ["predicate", "expected"], + ( + ( + lambda r: r.timestamp is None, + {"test1": [release.Release(Version("1.0.1"), 0, None)], "test2": []}, + ), + ( + lambda r: r.build_number == 1, + { + "test1": [ + release.Release( + Version("1.0.0"), 1, dt.datetime(2025, 12, 2, 20, 24, 40) + ) + ], + "test2": [], + }, + ), + ), +) +def test_filter_releases(releases, predicate, expected): + actual = release.filter_releases(predicate, releases) + assert actual == expected diff --git a/minimum_versions/tests/test_spec.py b/minimum_versions/tests/test_spec.py index 64b4252..df1c64d 100644 --- a/minimum_versions/tests/test_spec.py +++ b/minimum_versions/tests/test_spec.py @@ -1,7 +1,7 @@ import pytest from rattler import Version -from minimum_versions.spec import Spec +from minimum_versions.environments import Spec, conda @pytest.mark.parametrize( @@ -17,8 +17,8 @@ ), ), ) -def test_spec_parse(text, expected_spec, expected_name, expected_warnings): - actual_spec, (actual_name, actual_warnings) = Spec.parse(text) +def test_parse_conda_spec(text, expected_spec, expected_name, expected_warnings): + actual_spec, (actual_name, actual_warnings) = conda.parse_spec(text) assert actual_spec == expected_spec assert actual_name == expected_name diff --git a/pyproject.toml b/pyproject.toml index bacf560..0e241db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,40 @@ +[project] +name = "minimum-dependency-versions" +authors = [ + { name = "Justus Magin" }, +] +license = "Apache-2.0" +description = "Validate minimum dependency environments according to xarray's policy scheme" +requires-python = ">=3.11" +dependencies = [ + "rich", + "rich-click", + "pyyaml", + "cytoolz", + "py-rattler", + "python-dateutil", + "jsonschema", +] +dynamic = ["version"] + +[project.urls] +Repository = "https://github.com/xarray-contrib/minimum-dependency-versions" + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[tool.hatch] +version.source = "vcs" + +[tool.hatch.metadata.hooks.vcs] + +[tool.hatch.build.targets.sdist] +only-include = ["minimum_versions"] + +[tool.hatch.build.targets.wheel] +only-include = ["minimum_versions"] + [tool.ruff] target-version = "py39" builtins = ["ellipsis"] @@ -35,3 +72,11 @@ fixable = ["I", "TID252"] [tool.ruff.lint.flake8-tidy-imports] # Disallow all relative imports. ban-relative-imports = "all" + +[tool.coverage.run] +source = ["minimum_versions"] +branch = true + +[tool.coverage.report] +show_missing = true +exclude_lines = ["pragma: no cover", "if TYPE_CHECKING"]