From f2959bf6db3442707b8e1d40a206fb28067a9c91 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 29 Nov 2025 19:45:33 +0100 Subject: [PATCH 01/57] refactor `spec` into a separate submodule --- minimum_versions/environments/__init__.py | 2 + minimum_versions/environments/conda.py | 43 ++++++++++++++++ minimum_versions/environments/spec.py | 23 +++++++++ minimum_versions/spec.py | 63 ----------------------- 4 files changed, 68 insertions(+), 63 deletions(-) create mode 100644 minimum_versions/environments/__init__.py create mode 100644 minimum_versions/environments/conda.py create mode 100644 minimum_versions/environments/spec.py delete mode 100644 minimum_versions/spec.py diff --git a/minimum_versions/environments/__init__.py b/minimum_versions/environments/__init__.py new file mode 100644 index 0000000..945c42d --- /dev/null +++ b/minimum_versions/environments/__init__.py @@ -0,0 +1,2 @@ +from minimum_versions.environments.conda import parse_conda_environment # noqa: F401 +from minimum_versions.environments.spec import Spec, compare_versions # noqa: F401 diff --git a/minimum_versions/environments/conda.py b/minimum_versions/environments/conda.py new file mode 100644 index 0000000..932351a --- /dev/null +++ b/minimum_versions/environments/conda.py @@ -0,0 +1,43 @@ +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(data): + env = yaml.safe_load(data) + + 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/spec.py b/minimum_versions/environments/spec.py new file mode 100644 index 0000000..a244567 --- /dev/null +++ b/minimum_versions/environments/spec.py @@ -0,0 +1,23 @@ +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 > policy_versions[spec.name].version + ) + for spec in specs + ) + status[env] = env_status + return status 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 From cf5c4c0beb1576e579305908876461e3596cffb6 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 29 Nov 2025 19:52:03 +0100 Subject: [PATCH 02/57] dispatch by kind --- minimum_versions/environments/__init__.py | 14 ++++++++++++++ minimum_versions/main.py | 13 +++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/minimum_versions/environments/__init__.py b/minimum_versions/environments/__init__.py index 945c42d..d1c607e 100644 --- a/minimum_versions/environments/__init__.py +++ b/minimum_versions/environments/__init__.py @@ -1,2 +1,16 @@ from minimum_versions.environments.conda import parse_conda_environment # noqa: F401 from minimum_versions.environments.spec import Spec, compare_versions # noqa: F401 + +kinds = { + "conda": parse_conda_environment, +} + + +def parse_environment(specifier: str) -> list[Spec]: + kind, path = specifier.split(":", maxsplit=1) + + parser = kinds.get(kind) + if parser is None: + raise ValueError(f"Unknown kind {kind!r}, extracted from {specifier!r}.") + + return parser(path) diff --git a/minimum_versions/main.py b/minimum_versions/main.py index 0f3ffc0..c49e23b 100644 --- a/minimum_versions/main.py +++ b/minimum_versions/main.py @@ -1,5 +1,5 @@ import datetime -import pathlib +import os.path import sys import rich_click as click @@ -8,10 +8,10 @@ from rich.table import Table from tlz.itertoolz import concat +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 @@ -29,11 +29,7 @@ def main(): @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("--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): @@ -42,7 +38,8 @@ def validate(today, policy_file, environment_paths): 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) + for path in environment_paths } warnings = { From 9e1e9ecd3afae5d6c0c4d193828d90273e88625e Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 29 Nov 2025 19:53:08 +0100 Subject: [PATCH 03/57] default the kind to `conda` --- minimum_versions/environments/__init__.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/minimum_versions/environments/__init__.py b/minimum_versions/environments/__init__.py index d1c607e..072b46f 100644 --- a/minimum_versions/environments/__init__.py +++ b/minimum_versions/environments/__init__.py @@ -7,7 +7,12 @@ def parse_environment(specifier: str) -> list[Spec]: - kind, path = specifier.split(":", maxsplit=1) + 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: From 5a6b670074d148d666d41639cd70c4f4cabd2e63 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 29 Nov 2025 19:56:30 +0100 Subject: [PATCH 04/57] adapt the tests --- minimum_versions/tests/test_spec.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 From 7eef3e5c40c97c8140cb039a2f4bd122db9e2c03 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 29 Nov 2025 20:02:43 +0100 Subject: [PATCH 05/57] configure project metadata --- pyproject.toml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index bacf560..4a3f92c 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"] From a20e075874cf2556459f4d388b0636b00f6c2d36 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 29 Nov 2025 21:28:35 +0100 Subject: [PATCH 06/57] add `manifest_path` to the cli options --- minimum_versions/environments/__init__.py | 8 +++++--- minimum_versions/environments/conda.py | 6 ++++-- minimum_versions/main.py | 11 +++++++++-- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/minimum_versions/environments/__init__.py b/minimum_versions/environments/__init__.py index 072b46f..0faa7a2 100644 --- a/minimum_versions/environments/__init__.py +++ b/minimum_versions/environments/__init__.py @@ -1,4 +1,6 @@ -from minimum_versions.environments.conda import parse_conda_environment # noqa: F401 +import pathlib + +from minimum_versions.environments.conda import parse_conda_environment from minimum_versions.environments.spec import Spec, compare_versions # noqa: F401 kinds = { @@ -6,7 +8,7 @@ } -def parse_environment(specifier: str) -> list[Spec]: +def parse_environment(specifier: str, manifest_path: pathlib.Path | None) -> list[Spec]: split = specifier.split(":", maxsplit=1) if len(split) == 1: kind = "conda" @@ -18,4 +20,4 @@ def parse_environment(specifier: str) -> list[Spec]: if parser is None: raise ValueError(f"Unknown kind {kind!r}, extracted from {specifier!r}.") - return parser(path) + return parser(path, manifest_path) diff --git a/minimum_versions/environments/conda.py b/minimum_versions/environments/conda.py index 932351a..63a8884 100644 --- a/minimum_versions/environments/conda.py +++ b/minimum_versions/environments/conda.py @@ -1,3 +1,5 @@ +import pathlib + import yaml from rattler import Version @@ -29,8 +31,8 @@ def parse_spec(spec_text): return Spec(name, version), (name, warnings) -def parse_conda_environment(data): - env = yaml.safe_load(data) +def parse_conda_environment(path: pathlib.Path, manifest_path: None): + env = yaml.safe_load(pathlib.Path(path).read_text()) specs = [] warnings = [] diff --git a/minimum_versions/main.py b/minimum_versions/main.py index c49e23b..23bb048 100644 --- a/minimum_versions/main.py +++ b/minimum_versions/main.py @@ -1,5 +1,6 @@ import datetime import os.path +import pathlib import sys import rich_click as click @@ -30,15 +31,21 @@ def main(): @main.command() @click.argument("environment_paths", type=str, nargs=-1) +@click.option( + "--manifest-path", + "manifest_path", + type=click.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.rsplit(os.path.sep, maxsplit=1)[-1]: parse_environment(path) + path.rsplit(os.path.sep, maxsplit=1)[-1]: parse_environment(path, manifest_path) for path in environment_paths } From 8f37fefc0ab67da678b5efff1f938abc734f08a9 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 29 Nov 2025 21:29:23 +0100 Subject: [PATCH 07/57] formatting --- minimum_versions/environments/conda.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/minimum_versions/environments/conda.py b/minimum_versions/environments/conda.py index 63a8884..27dcfc0 100644 --- a/minimum_versions/environments/conda.py +++ b/minimum_versions/environments/conda.py @@ -10,7 +10,8 @@ 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." + 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("<", "") From 9f9990e7944409f0c9ef662dfe65e26f44f1d306 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 29 Nov 2025 21:29:35 +0100 Subject: [PATCH 08/57] write the environment parsing for pixi envs --- minimum_versions/environments/__init__.py | 2 + minimum_versions/environments/pixi.py | 102 ++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 minimum_versions/environments/pixi.py diff --git a/minimum_versions/environments/__init__.py b/minimum_versions/environments/__init__.py index 0faa7a2..a4df9d1 100644 --- a/minimum_versions/environments/__init__.py +++ b/minimum_versions/environments/__init__.py @@ -1,10 +1,12 @@ 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, } diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py new file mode 100644 index 0000000..b3ec25f --- /dev/null +++ b/minimum_versions/environments/pixi.py @@ -0,0 +1,102 @@ +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): + # "*" => None + # "x.y.*" => "x.y" + # ">=x.y.0, "x.y" (+ warning) + # ">=x.y.*" => "x.y" (+ warning) + + 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"Unknown version format: {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 == "pixi.toml": + pixi_config = data + else: + pixi_config = get_in(["pixi", "tool"], data, None) + if pixi_config is None: + raise ValueError( + f"The 'tool.pixi' section is missing from {manifest_path}." + ) + + 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") + if all_features is None: + raise ValueError("No features found in the pixi config.") + + env = environment_definitions.get(name) + if env is None: + raise ValueError(f"Unknown environment: {name}") + + features = [ + get_in([feature, "dependencies"], all_features) for feature in env["features"] + ] + + pins = merge(features) + + specs = [] + warnings = [] + for name, pin in pins.items(): + spec, warnings_ = parse_spec(name, pin) + + specs.append(spec) + warnings.append(warnings_) + + return specs, warnings From 1c0c0eeb3bdd9655847d98e9f788157d29277f82 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sun, 30 Nov 2025 15:46:55 +0100 Subject: [PATCH 09/57] raise a more descriptive error if no suitable releases were found --- minimum_versions/policy.py | 3 +++ 1 file changed, 3 insertions(+) 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] From fd070576f8af13b3254f4a8366e5a3463591e9f9 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sun, 30 Nov 2025 15:47:53 +0100 Subject: [PATCH 10/57] filter out excluded packages before fetching releases --- minimum_versions/main.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/minimum_versions/main.py b/minimum_versions/main.py index 23bb048..29cda01 100644 --- a/minimum_versions/main.py +++ b/minimum_versions/main.py @@ -7,7 +7,7 @@ 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 @@ -58,7 +58,11 @@ def validate(today, policy_file, manifest_path, 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) From f1e6de1efd06ab52fc675c408acc63c7b9e0b78f Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sun, 30 Nov 2025 15:49:45 +0100 Subject: [PATCH 11/57] consider unpinned specs as not matching --- minimum_versions/environments/spec.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/minimum_versions/environments/spec.py b/minimum_versions/environments/spec.py index a244567..f93fa94 100644 --- a/minimum_versions/environments/spec.py +++ b/minimum_versions/environments/spec.py @@ -15,7 +15,10 @@ def compare_versions(environments, policy_versions, ignored_violations): env_status = any( ( spec.name not in ignored_violations - and spec.version > policy_versions[spec.name].version + and ( + spec.version is None + or spec.version > policy_versions[spec.name].version + ) ) for spec in specs ) From 5883e35c07ca10ee22e15bc26f97484ff3de0627 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sun, 30 Nov 2025 15:58:39 +0100 Subject: [PATCH 12/57] gracefully handle unpinned but not ignored dependencies --- minimum_versions/formatting.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/minimum_versions/formatting.py b/minimum_versions/formatting.py index 7c9c879..a6f47da 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), + "!": Style(color="#ff0000", bold=True), } 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, From 0bb207d34727e90a693326056dc5e4a6fb889482 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sun, 30 Nov 2025 16:09:02 +0100 Subject: [PATCH 13/57] install the module instead of modifying `sys.path` --- action.yaml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/action.yaml b/action.yaml index 82a9fdc..4deddc3 100644 --- a/action.yaml +++ b/action.yaml @@ -28,6 +28,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} @@ -38,4 +39,7 @@ runs: ENVIRONMENT_PATHS: ${{ inputs.environment-paths }} TODAY: ${{ inputs.today }} 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" \ + $ENVIRONMENT_PATHS From 6795a27caf00cd1485068eab6d0c4efdc4cca780 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sun, 30 Nov 2025 16:27:10 +0100 Subject: [PATCH 14/57] allow passing `manifest-path` to the action --- action.yaml | 8 +++++++- minimum_versions/main.py | 13 ++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/action.yaml b/action.yaml index 4deddc3..8683eb7 100644 --- a/action.yaml +++ b/action.yaml @@ -17,8 +17,12 @@ inputs: Time machine for testing required: false type: string + manifest-path: >- + description: >- + Path to the manifest file of `pixi`. Required for `pixi` environments. + required: false + type: string outputs: {} - runs: using: "composite" @@ -38,8 +42,10 @@ runs: POLICY_PATH: ${{ inputs.policy }} ENVIRONMENT_PATHS: ${{ inputs.environment-paths }} TODAY: ${{ inputs.today }} + MANIFEST_PATH: ${{ inputs.manifest-path }} run: | python -m minimum_versions validate \ --today="$TODAY" \ --policy="$POLICY_PATH" \ + --manifest-path="$MANIFEST_PATH" \ $ENVIRONMENT_PATHS diff --git a/minimum_versions/main.py b/minimum_versions/main.py index 29cda01..4857c2a 100644 --- a/minimum_versions/main.py +++ b/minimum_versions/main.py @@ -2,6 +2,7 @@ import os.path import pathlib import sys +from typing import Any import rich_click as click from rich.console import Console @@ -24,6 +25,16 @@ 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 @@ -34,7 +45,7 @@ def main(): @click.option( "--manifest-path", "manifest_path", - type=click.Path(exists=True, path_type=pathlib.Path), + type=_Path(exists=True, path_type=pathlib.Path), default=None, ) @click.option("--today", type=parse_date, default=None) From 851d0a6eacbdc0dc1bae069427862f0d2012ee46 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sun, 30 Nov 2025 16:37:57 +0100 Subject: [PATCH 15/57] misformatted input definition --- action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yaml b/action.yaml index 8683eb7..a59cb67 100644 --- a/action.yaml +++ b/action.yaml @@ -17,7 +17,7 @@ inputs: Time machine for testing required: false type: string - manifest-path: >- + manifest-path: description: >- Path to the manifest file of `pixi`. Required for `pixi` environments. required: false From 0144607d51e6d3792a4d05661f0e6f4921498822 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sun, 30 Nov 2025 16:39:33 +0100 Subject: [PATCH 16/57] stop testing on the unsupported python 3.10 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0be3c0f..abd54e2 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 From 741a436f02ef597c80814cf0e7660ee1dcb269cf Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sun, 30 Nov 2025 16:39:47 +0100 Subject: [PATCH 17/57] install the package itself --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index abd54e2..2af15ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: | From f56c537da235ad302512d24703784706eb75823b Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sun, 30 Nov 2025 22:14:40 +0100 Subject: [PATCH 18/57] use the warning style for unpinned versions --- minimum_versions/formatting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/minimum_versions/formatting.py b/minimum_versions/formatting.py index a6f47da..ba6fca9 100644 --- a/minimum_versions/formatting.py +++ b/minimum_versions/formatting.py @@ -47,7 +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), - "!": Style(color="#ff0000", bold=True), + "!": warning_style, } for spec in specs: From afd85b45cadac14d6c4ac7d8897f4f15e1dc702c Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sun, 30 Nov 2025 22:32:37 +0100 Subject: [PATCH 19/57] warn about ignored PyPI dependencies --- minimum_versions/environments/pixi.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py index b3ec25f..48a00c6 100644 --- a/minimum_versions/environments/pixi.py +++ b/minimum_versions/environments/pixi.py @@ -93,6 +93,16 @@ def parse_pixi_environment(name: str, manifest_path: pathlib.Path | None): specs = [] warnings = [] + + pypi_dependencies = { + feature: get_in([feature, "pypi-dependencies"], all_features, default=[]) + for feature in env["features"] + } + 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 name, pin in pins.items(): spec, warnings_ = parse_spec(name, pin) From 51da18a663b9939c4d44e9f8f1be139aecece4b1 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 2 Dec 2025 20:47:15 +0100 Subject: [PATCH 20/57] tests for most of the functions in `minimum_versions.release` --- minimum_versions/tests/test_release.py | 112 +++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 minimum_versions/tests/test_release.py 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 From c53cf3314e3db4dab697562c1e5075456bd7b10e Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 21:16:49 +0100 Subject: [PATCH 21/57] tests for the environment functions --- minimum_versions/tests/test_environments.py | 82 +++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 minimum_versions/tests/test_environments.py diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py new file mode 100644 index 0000000..c83f5fb --- /dev/null +++ b/minimum_versions/tests/test_environments.py @@ -0,0 +1,82 @@ +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 From 36019e333aeaaee51877d9a0390689f741b07d21 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 21:29:58 +0100 Subject: [PATCH 22/57] tests for parsing conda specs --- minimum_versions/tests/test_environments.py | 66 +++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index c83f5fb..b53d744 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -80,3 +80,69 @@ def test_compare_versions(envs, ignored_violations, expected): envs, policy_versions, ignored_violations ) assert actual == expected + + +class TestParseCondaEnvironment: + @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 From a1c08cff99630a6e9ce62d56932dea0ae8943671 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 21:53:55 +0100 Subject: [PATCH 23/57] also check parsing the entire conda env --- minimum_versions/tests/test_environments.py | 39 +++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index b53d744..7e7adfe 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -1,3 +1,5 @@ +import pathlib +import textwrap from dataclasses import dataclass import pytest @@ -146,3 +148,40 @@ def test_parse_spec(self, spec_text, expected_spec, expected_warnings): 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 From 8f0fa55cec9c53f1cb8676ebca6c8927a94cfde4 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:01:55 +0100 Subject: [PATCH 24/57] rename --- minimum_versions/tests/test_environments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index 7e7adfe..ff8dcf3 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -84,7 +84,7 @@ def test_compare_versions(envs, ignored_violations, expected): assert actual == expected -class TestParseCondaEnvironment: +class TestCondaEnvironment: @pytest.mark.parametrize( ["spec_text", "expected_spec", "expected_warnings"], ( From b979674aa5c41549788000646d82a584841b8aa3 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:06:32 +0100 Subject: [PATCH 25/57] check parsing pixi specs --- minimum_versions/tests/test_environments.py | 40 +++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index ff8dcf3..f60b4d2 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -185,3 +185,43 @@ def test_parse_environment(self, monkeypatch): 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", + ), + ), + ) + 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 From 878aa35c4c2e1afc2fa524edc388520bee5d6295 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:06:45 +0100 Subject: [PATCH 26/57] fix a bug in the lower pin regex --- minimum_versions/environments/pixi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py index 48a00c6..d520616 100644 --- a/minimum_versions/environments/pixi.py +++ b/minimum_versions/environments/pixi.py @@ -7,7 +7,7 @@ from minimum_versions.environments.spec import Spec -_version_re = r"[0-9]+\.[0-9]+(?:\.[0-9]+|\.\*)" +_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})") From 9cfadc7ebbc3bbbd46e7adb9aa1df8df9b4ec68d Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:10:25 +0100 Subject: [PATCH 27/57] additional spec parsing checks --- minimum_versions/tests/test_environments.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index f60b4d2..9dcd12c 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -215,6 +215,14 @@ class TestPixiEnvironment: ], 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"), ), ) def test_parse_spec(self, name, version_text, expected_spec, expected_warnings): From 7f9863b7a7ee42537104e77825acb8d84e905333 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:14:01 +0100 Subject: [PATCH 28/57] check invalid versions raise --- minimum_versions/tests/test_environments.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index 9dcd12c..1ea2362 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -233,3 +233,8 @@ def test_parse_spec(self, name, version_text, expected_spec, expected_warnings): 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")) + def test_parse_spec_error(self, version_text): + with pytest.raises(ValueError, match="Unknown version format: .*"): + environments.pixi.parse_spec("package", version_text) From c7c1179f490238bfeab76ceca50444676e31dbc3 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:25:26 +0100 Subject: [PATCH 29/57] checks for parse_pixi_environment --- minimum_versions/tests/test_environments.py | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index 1ea2362..b315a7d 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -1,3 +1,4 @@ +import io import pathlib import textwrap from dataclasses import dataclass @@ -238,3 +239,36 @@ def test_parse_spec(self, name, version_text, expected_spec, expected_warnings): def test_parse_spec_error(self, version_text): with pytest.raises(ValueError, match="Unknown version format: .*"): environments.pixi.parse_spec("package", version_text) + + def test_parse_pixi_environment(self, monkeypatch): + data = textwrap.dedent( + """\ + [dependencies] + a = "1.0.*" + b = "2.2.*" + + [feature.feature1.dependencies] + c = "3.1.*" + + [environments] + env1 = { features = ["feature1"] } + """.rstrip() + ) + monkeypatch.setattr( + pathlib.Path, "open", lambda _, mode: io.BytesIO(data.encode()) + ) + + name = "env1" + manifest_path = pathlib.Path("pixi.toml") + + actual_specs, actual_warnings = environments.pixi.parse_pixi_environment( + name, manifest_path + ) + expected_specs = [ + Spec("a", Version("1.0")), + Spec("b", Version("2.2")), + Spec("c", Version("3.1")), + ] + expected_warnings = [("a", []), ("b", []), ("c", [])] + assert actual_specs == expected_specs + assert actual_warnings == expected_warnings From c2351bf1a0af361607a3010a798f44d97f588829 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:25:44 +0100 Subject: [PATCH 30/57] include the default feature in the features --- minimum_versions/environments/pixi.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py index d520616..1426842 100644 --- a/minimum_versions/environments/pixi.py +++ b/minimum_versions/environments/pixi.py @@ -85,8 +85,15 @@ def parse_pixi_environment(name: str, manifest_path: pathlib.Path | None): if env is None: raise ValueError(f"Unknown environment: {name}") - features = [ - get_in([feature, "dependencies"], all_features) for feature in env["features"] + if isinstance(env, list): + feature_names = env + elif isinstance(env, dict) and list(env) != ["features"]: + raise ValueError("Options other than 'features' are not supported.") + else: + feature_names = env["features"] + + features = [pixi_config.get("dependencies", [])] + [ + get_in([feature, "dependencies"], all_features) for feature in feature_names ] pins = merge(features) From abf286ff72cfeb58414433e0c03cd3fd2b54da6f Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:41:15 +0100 Subject: [PATCH 31/57] rename the `environment-paths` input to `environments` --- action.yaml | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/action.yaml b/action.yaml index a59cb67..00ca955 100644 --- a/action.yaml +++ b/action.yaml @@ -7,16 +7,18 @@ inputs: The path to the policy to follow required: true type: string - environment-paths: - description: >- - The paths to the environment files - required: true - type: list today: description: >- Time machine for testing required: false type: string + environments: + description: >- + 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 manifest-path: description: >- Path to the manifest file of `pixi`. Required for `pixi` environments. @@ -40,7 +42,7 @@ runs: COLUMNS: 120 FORCE_COLOR: 3 POLICY_PATH: ${{ inputs.policy }} - ENVIRONMENT_PATHS: ${{ inputs.environment-paths }} + ENVIRONMENTS: ${{ inputs.environment }} TODAY: ${{ inputs.today }} MANIFEST_PATH: ${{ inputs.manifest-path }} run: | @@ -48,4 +50,4 @@ runs: --today="$TODAY" \ --policy="$POLICY_PATH" \ --manifest-path="$MANIFEST_PATH" \ - $ENVIRONMENT_PATHS + $ENVIRONMENTS From 8473575f34412c9dda64fec2770fb29a70d39e93 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:41:51 +0100 Subject: [PATCH 32/57] describe how to analyze `pixi` environments --- README.md | 65 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 61 insertions(+), 4 deletions(-) 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 ``` From 2ed43755b19990b5ad0da485ddd3420251e7ab59 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:47:59 +0100 Subject: [PATCH 33/57] rename `env-paths` to `envs` --- .github/workflows/ci.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2af15ba..1ea8acc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: strategy: fail-fast: false matrix: - env-paths: + envs: - "envs/env1.yaml" - "envs/env2.yaml" - | @@ -53,16 +53,16 @@ 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" + - envs: "envs/env1.yaml" policy-file: policy_no_extra_options.yaml expected-failure: "false" @@ -75,8 +75,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-paths }} - name: detect outcome if: always() shell: bash -l {0} From 81c9530c00e47068c6270e9b0b2041e393f80820 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:50:47 +0100 Subject: [PATCH 34/57] e2e tests for pixi and mixed envs --- .github/workflows/ci.yml | 10 ++++++++++ envs/pixi.toml | 14 ++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 envs/pixi.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ea8acc..b2bdba1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,16 @@ jobs: - envs: "envs/env1.yaml" policy-file: policy_no_extra_options.yaml expected-failure: "false" + - envs: pixi:env1 + manifest-path: envs/pixi.toml + - envs: | + pixi:env1 + pixi:env2 + manifest-path: envs/pixi.toml + - envs: | + pixi:env1 + conda:envs/env2.yaml + manifest-path: envs/pixi.toml steps: - name: clone the repository diff --git a/envs/pixi.toml b/envs/pixi.toml new file mode 100644 index 0000000..06a5ffd --- /dev/null +++ b/envs/pixi.toml @@ -0,0 +1,14 @@ +[dependencies] +numpy = "1.24" +pandas = "2.1" +packaging = "23.1" + +[feature.py310.dependencies] +python = "3.10" + +[feature.py311.dependencies] +python = "3.11" + +[environments] +env1 = { features = ["py310"] } +env2 = ["py311"] From cc8ee846e05705779c42c73552ccbc68372ff73b Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:52:24 +0100 Subject: [PATCH 35/57] typo --- action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yaml b/action.yaml index 00ca955..cf3e5bf 100644 --- a/action.yaml +++ b/action.yaml @@ -42,7 +42,7 @@ runs: COLUMNS: 120 FORCE_COLOR: 3 POLICY_PATH: ${{ inputs.policy }} - ENVIRONMENTS: ${{ inputs.environment }} + ENVIRONMENTS: ${{ inputs.environments }} TODAY: ${{ inputs.today }} MANIFEST_PATH: ${{ inputs.manifest-path }} run: | From 3f55bc2805573c8bf62ecd47282524a522a732eb Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:53:40 +0100 Subject: [PATCH 36/57] add policy files for the pixi tests --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2bdba1..c290dc0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,14 +67,17 @@ jobs: expected-failure: "false" - envs: pixi:env1 manifest-path: envs/pixi.toml + policy-file: "policy.yaml" - envs: | pixi:env1 pixi:env2 manifest-path: envs/pixi.toml + policy-file: "policy.yaml" - envs: | pixi:env1 conda:envs/env2.yaml manifest-path: envs/pixi.toml + policy-file: "policy.yaml" steps: - name: clone the repository From a5ac0082157ef9ef4c9f2c0bf62895a1dea4b097 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:55:39 +0100 Subject: [PATCH 37/57] another typo --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c290dc0..ba80b4e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,7 +90,7 @@ jobs: policy: ${{ matrix.policy-file }} environments: ${{ matrix.envs }} today: 2024-12-20 - manifest-path: ${{ matrix.manifest-paths }} + manifest-path: ${{ matrix.manifest-path }} - name: detect outcome if: always() shell: bash -l {0} From 9d266d93d60b696d4590ef172433e5390b5cef91 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:59:05 +0100 Subject: [PATCH 38/57] quotes and expected failure settings --- .github/workflows/ci.yml | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba80b4e..75d4920 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,21 +63,24 @@ jobs: policy-file: "policy.yaml" expected-failure: "true" - envs: "envs/env1.yaml" - policy-file: policy_no_extra_options.yaml + policy-file: "policy_no_extra_options.yaml" expected-failure: "false" - - envs: pixi:env1 - manifest-path: envs/pixi.toml + - 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 + manifest-path: "envs/pixi.toml" policy-file: "policy.yaml" + expected-failure: "false" - envs: | pixi:env1 conda:envs/env2.yaml - manifest-path: envs/pixi.toml + manifest-path: "envs/pixi.toml" policy-file: "policy.yaml" + expected-failure: "false" steps: - name: clone the repository From 0d5add7efbd41aaff03f2b26a33f144ec58894ff Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 22:59:16 +0100 Subject: [PATCH 39/57] add a failing pixi env --- .github/workflows/ci.yml | 4 ++++ envs/pixi.toml | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75d4920..42a5162 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,10 @@ jobs: 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 diff --git a/envs/pixi.toml b/envs/pixi.toml index 06a5ffd..bcef0d0 100644 --- a/envs/pixi.toml +++ b/envs/pixi.toml @@ -1,5 +1,4 @@ [dependencies] -numpy = "1.24" pandas = "2.1" packaging = "23.1" @@ -9,6 +8,11 @@ python = "3.10" [feature.py311.dependencies] python = "3.11" +[feature.failing.dependencies] +numpy = "2.1" + + [environments] env1 = { features = ["py310"] } env2 = ["py311"] +failing-env = { features = ["failing"] } From 35ac1e9526ebb20dcdd9e9ac594578ef3d89f488 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 23:03:46 +0100 Subject: [PATCH 40/57] also check the default pypi-dependencies --- minimum_versions/environments/pixi.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py index 1426842..e2ba832 100644 --- a/minimum_versions/environments/pixi.py +++ b/minimum_versions/environments/pixi.py @@ -86,14 +86,19 @@ def parse_pixi_environment(name: str, manifest_path: pathlib.Path | None): raise ValueError(f"Unknown environment: {name}") if isinstance(env, list): - feature_names = env + feature_names = ["default"] + env elif isinstance(env, dict) and list(env) != ["features"]: raise ValueError("Options other than 'features' are not supported.") else: - feature_names = env["features"] + feature_names = ["default"] + env["features"] - features = [pixi_config.get("dependencies", [])] + [ - get_in([feature, "dependencies"], all_features) for feature in feature_names + features = [ + ( + get_in([feature, "dependencies"], all_features) + if feature != "default" + else pixi_config.get("dependencies", []) + ) + for feature in feature_names ] pins = merge(features) @@ -102,8 +107,12 @@ def parse_pixi_environment(name: str, manifest_path: pathlib.Path | None): warnings = [] pypi_dependencies = { - feature: get_in([feature, "pypi-dependencies"], all_features, default=[]) - for feature in env["features"] + 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 From b5844765da598b3d50efbbd31bfcfce03004dfe2 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Mon, 8 Dec 2025 23:05:48 +0100 Subject: [PATCH 41/57] back to `python=3.9` --- envs/pixi.toml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/envs/pixi.toml b/envs/pixi.toml index bcef0d0..365e095 100644 --- a/envs/pixi.toml +++ b/envs/pixi.toml @@ -2,17 +2,16 @@ pandas = "2.1" packaging = "23.1" +[feature.py39.dependencies] +python = "3.9" + [feature.py310.dependencies] python = "3.10" -[feature.py311.dependencies] -python = "3.11" - [feature.failing.dependencies] numpy = "2.1" - [environments] env1 = { features = ["py310"] } -env2 = ["py311"] +env2 = ["py39"] failing-env = { features = ["failing"] } From 2d1976e7a2f46c88ccd268f6bfd52afd5b7fc11d Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 19:33:05 +0100 Subject: [PATCH 42/57] support the `no-default-feature` option --- minimum_versions/environments/pixi.py | 15 +++-- minimum_versions/tests/test_environments.py | 64 +++++++++++++++------ 2 files changed, 57 insertions(+), 22 deletions(-) diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py index e2ba832..a0b0e01 100644 --- a/minimum_versions/environments/pixi.py +++ b/minimum_versions/environments/pixi.py @@ -86,11 +86,18 @@ def parse_pixi_environment(name: str, manifest_path: pathlib.Path | None): raise ValueError(f"Unknown environment: {name}") if isinstance(env, list): - feature_names = ["default"] + env - elif isinstance(env, dict) and list(env) != ["features"]: - raise ValueError("Options other than 'features' are not supported.") + 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: - feature_names = ["default"] + env["features"] + raise ValueError("unexpected environment type") features = [ ( diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index b315a7d..4db5c34 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -240,20 +240,54 @@ def test_parse_spec_error(self, version_text): with pytest.raises(ValueError, match="Unknown version format: .*"): environments.pixi.parse_spec("package", version_text) - def test_parse_pixi_environment(self, monkeypatch): - data = textwrap.dedent( - """\ - [dependencies] - a = "1.0.*" - b = "2.2.*" + @pytest.mark.parametrize( + ["data", "expected_specs", "expected_warnings"], + ( + pytest.param( + textwrap.dedent( + """\ + [dependencies] + a = "1.0.*" + b = "2.2.*" - [feature.feature1.dependencies] - c = "3.1.*" + [feature.feature1.dependencies] + c = "3.1.*" - [environments] - env1 = { features = ["feature1"] } - """.rstrip() - ) + [environments] + env1 = { features = ["feature1"] } + """.rstrip() + ), + [ + 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() + ), + [Spec("c", Version("3.1"))], + [("c", [])], + id="no-default-feature", + ), + ), + ) + def test_parse_pixi_environment( + self, monkeypatch, data, expected_specs, expected_warnings + ): monkeypatch.setattr( pathlib.Path, "open", lambda _, mode: io.BytesIO(data.encode()) ) @@ -264,11 +298,5 @@ def test_parse_pixi_environment(self, monkeypatch): actual_specs, actual_warnings = environments.pixi.parse_pixi_environment( name, manifest_path ) - expected_specs = [ - Spec("a", Version("1.0")), - Spec("b", Version("2.2")), - Spec("c", Version("3.1")), - ] - expected_warnings = [("a", []), ("b", []), ("c", [])] assert actual_specs == expected_specs assert actual_warnings == expected_warnings From 3017270385b739502f385471ab49678722b58838 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 19:44:38 +0100 Subject: [PATCH 43/57] support analyzing missing features --- minimum_versions/environments/pixi.py | 4 +--- minimum_versions/tests/test_environments.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py index a0b0e01..46b73a1 100644 --- a/minimum_versions/environments/pixi.py +++ b/minimum_versions/environments/pixi.py @@ -77,9 +77,7 @@ def parse_pixi_environment(name: str, manifest_path: pathlib.Path | None): if environment_definitions is None: raise ValueError("Can't find environments in the pixi config.") - all_features = pixi_config.get("feature") - if all_features is None: - raise ValueError("No features found in the pixi config.") + all_features = pixi_config.get("feature", {}) env = environment_definitions.get(name) if env is None: diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index 4db5c34..38dc664 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -283,6 +283,20 @@ def test_parse_spec_error(self, version_text): [("c", [])], id="no-default-feature", ), + pytest.param( + textwrap.dedent( + """\ + [dependencies] + a = "1.0.*" + + [environments] + env1 = { features = [] } + """.rstrip() + ), + [Spec("a", Version("1.0"))], + [("a", [])], + id="missing-features", + ), ), ) def test_parse_pixi_environment( From 285aee0767e3dc83d36341bd37664620c2ef9c6a Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 19:46:23 +0100 Subject: [PATCH 44/57] change the error text for unknown version specs --- minimum_versions/environments/pixi.py | 2 +- minimum_versions/tests/test_environments.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py index 46b73a1..4296b2d 100644 --- a/minimum_versions/environments/pixi.py +++ b/minimum_versions/environments/pixi.py @@ -42,7 +42,7 @@ def parse_spec(name, version_text): raw_version = lower_pin else: - raise ValueError(f"Unknown version format: {version_text}") + raise ValueError(f"Unsupported version spec: {version_text}") if raw_version is not None: version = Version(raw_version.removesuffix(".*")) diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index 38dc664..4549b08 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -237,7 +237,7 @@ def test_parse_spec(self, name, version_text, expected_spec, expected_warnings): @pytest.mark.parametrize("version_text", ("~1.3", "^2.1", "<1.1")) def test_parse_spec_error(self, version_text): - with pytest.raises(ValueError, match="Unknown version format: .*"): + with pytest.raises(ValueError, match="Unsupported version spec: .*"): environments.pixi.parse_spec("package", version_text) @pytest.mark.parametrize( From fdfa6b61dbe474ba40d6fd5329179e298932dab9 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 19:53:22 +0100 Subject: [PATCH 45/57] add a note containing the package name --- minimum_versions/environments/pixi.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py index 4296b2d..23d7bd6 100644 --- a/minimum_versions/environments/pixi.py +++ b/minimum_versions/environments/pixi.py @@ -125,7 +125,11 @@ def parse_pixi_environment(name: str, manifest_path: pathlib.Path | None): for feature in with_pypi_dependencies: warnings.append((f"feature:{feature}", ["Ignored PyPI dependencies."])) for name, pin in pins.items(): - spec, warnings_ = parse_spec(name, pin) + try: + spec, warnings_ = parse_spec(name, pin) + except ValueError as e: + e.add_note(f"package name: {name}") + raise specs.append(spec) warnings.append(warnings_) From f0fd56db4fcecefe26464858b21435234d7d27f8 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 19:54:51 +0100 Subject: [PATCH 46/57] add more information --- minimum_versions/environments/pixi.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py index 23d7bd6..4dccb27 100644 --- a/minimum_versions/environments/pixi.py +++ b/minimum_versions/environments/pixi.py @@ -124,11 +124,11 @@ def parse_pixi_environment(name: str, manifest_path: pathlib.Path | None): } for feature in with_pypi_dependencies: warnings.append((f"feature:{feature}", ["Ignored PyPI dependencies."])) - for name, pin in pins.items(): + for package_name, pin in pins.items(): try: - spec, warnings_ = parse_spec(name, pin) + spec, warnings_ = parse_spec(package_name, pin) except ValueError as e: - e.add_note(f"package name: {name}") + e.add_note(f"environment {name}: {package_name}{pin}") raise specs.append(spec) From c95acf76683afa1780b21234d69cf63e02c10b57 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 19:55:06 +0100 Subject: [PATCH 47/57] support dict pins --- minimum_versions/environments/pixi.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py index 4dccb27..ad3a953 100644 --- a/minimum_versions/environments/pixi.py +++ b/minimum_versions/environments/pixi.py @@ -13,12 +13,15 @@ tight_pin_re = re.compile(rf">=(?P{_version_re}),<(?P{_version_re})") -def parse_spec(name, version_text): +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 From 56aab763eb47a695527bcb4bed97d5fcca371f8a Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 20:07:24 +0100 Subject: [PATCH 48/57] configure coverage --- pyproject.toml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 4a3f92c..0e241db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,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"] From 113d767986be786362de9ae335e1adf08c1fafef Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 20:08:11 +0100 Subject: [PATCH 49/57] check the format detection --- minimum_versions/environments/pixi.py | 8 +++---- minimum_versions/tests/test_environments.py | 24 ++++++++++++++++++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py index ad3a953..c533f3e 100644 --- a/minimum_versions/environments/pixi.py +++ b/minimum_versions/environments/pixi.py @@ -67,14 +67,14 @@ def parse_pixi_environment(name: str, manifest_path: pathlib.Path | None): with manifest_path.open(mode="rb") as f: data = tomllib.load(f) - if manifest_path.name == "pixi.toml": - pixi_config = data - else: - pixi_config = get_in(["pixi", "tool"], data, None) + 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: diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index 4549b08..bd37117 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -241,7 +241,7 @@ def test_parse_spec_error(self, version_text): environments.pixi.parse_spec("package", version_text) @pytest.mark.parametrize( - ["data", "expected_specs", "expected_warnings"], + ["data", "path", "expected_specs", "expected_warnings"], ( pytest.param( textwrap.dedent( @@ -257,6 +257,7 @@ def test_parse_spec_error(self, version_text): env1 = { features = ["feature1"] } """.rstrip() ), + "pixi.toml", [ Spec("a", Version("1.0")), Spec("b", Version("2.2")), @@ -279,6 +280,7 @@ def test_parse_spec_error(self, version_text): env1 = { features = ["feature1"], no-default-feature = true } """.rstrip() ), + "pixi.toml", [Spec("c", Version("3.1"))], [("c", [])], id="no-default-feature", @@ -293,21 +295,37 @@ def test_parse_spec_error(self, version_text): env1 = { features = [] } """.rstrip() ), + "pixi.toml", [Spec("a", Version("1.0"))], [("a", [])], id="missing-features", ), + 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", + ), ), ) def test_parse_pixi_environment( - self, monkeypatch, data, expected_specs, expected_warnings + 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("pixi.toml") + manifest_path = pathlib.Path(path) actual_specs, actual_warnings = environments.pixi.parse_pixi_environment( name, manifest_path From 4dcc7dcd236aeb34450647c55e56ac93833cb135 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 20:09:13 +0100 Subject: [PATCH 50/57] raise on unknown features --- minimum_versions/environments/pixi.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py index c533f3e..59fe6f1 100644 --- a/minimum_versions/environments/pixi.py +++ b/minimum_versions/environments/pixi.py @@ -100,6 +100,12 @@ def parse_pixi_environment(name: str, manifest_path: pathlib.Path | None): 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) From 81715497315fd6850c334f1e49c30db3f1336354 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 20:09:42 +0100 Subject: [PATCH 51/57] support no features --- minimum_versions/environments/pixi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py index 59fe6f1..31ad8fd 100644 --- a/minimum_versions/environments/pixi.py +++ b/minimum_versions/environments/pixi.py @@ -108,7 +108,7 @@ def parse_pixi_environment(name: str, manifest_path: pathlib.Path | None): features = [ ( - get_in([feature, "dependencies"], all_features) + get_in([feature, "dependencies"], all_features, {}) if feature != "default" else pixi_config.get("dependencies", []) ) From 2555235114b2aae574e4dccf60a6eb3285d49318 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 20:10:06 +0100 Subject: [PATCH 52/57] check that `<=` is also detected --- minimum_versions/tests/test_environments.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index bd37117..dd93b5c 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -235,7 +235,7 @@ def test_parse_spec(self, name, version_text, expected_spec, expected_warnings): assert actual_name == name assert actual_warnings == expected_warnings - @pytest.mark.parametrize("version_text", ("~1.3", "^2.1", "<1.1")) + @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) From 99672dda38dea13dd5f51b0bdd14d2d7a3ab1c00 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 20:10:38 +0100 Subject: [PATCH 53/57] check pypi dependencies --- minimum_versions/tests/test_environments.py | 36 +++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index dd93b5c..c9b311b 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -300,6 +300,42 @@ def test_parse_spec_error(self, version_text): [("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( """\ From 7bad70de5462710af38eb2cd44d70de09e524d36 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 20:12:12 +0100 Subject: [PATCH 54/57] support local packages --- minimum_versions/tests/test_environments.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index c9b311b..723bdc4 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -224,6 +224,7 @@ class TestPixiEnvironment: 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): @@ -351,6 +352,24 @@ def test_parse_spec_error(self, version_text): [("c", [])], id="pyproject", ), + pytest.param( + textwrap.dedent( + """\ + [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( From 798e579bfcb4e3057d98a62371ca59f9e941eafc Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 20:23:39 +0100 Subject: [PATCH 55/57] skip the local package, if any --- minimum_versions/environments/pixi.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py index 31ad8fd..60e634a 100644 --- a/minimum_versions/environments/pixi.py +++ b/minimum_versions/environments/pixi.py @@ -115,7 +115,13 @@ def parse_pixi_environment(name: str, manifest_path: pathlib.Path | None): for feature in feature_names ] - pins = merge(features) + 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 = [] From 5e76d15a5f374711439cfde35face2d3a2c91834 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Tue, 9 Dec 2025 20:25:54 +0100 Subject: [PATCH 56/57] properly check that local packages are skipped --- minimum_versions/tests/test_environments.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py index 723bdc4..1592d31 100644 --- a/minimum_versions/tests/test_environments.py +++ b/minimum_versions/tests/test_environments.py @@ -355,6 +355,9 @@ def test_parse_spec_error(self, version_text): pytest.param( textwrap.dedent( """\ + [package] + name = "a" + [dependencies] a = { path = "." } From 7420881fc813c3eec2cfbf11fed8e6b91991fd48 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Wed, 10 Dec 2025 21:13:36 +0100 Subject: [PATCH 57/57] add information about the environments --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a11fa60..6c1a61f 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Then add a new step to CI. To analyze conda environments, simply pass the path to the environment file (`env.yaml`) to the `environments` key. +The conda environment file _must_ specify exactly the `conda-forge` channel. + ```yaml jobs: my-job: @@ -79,7 +81,9 @@ jobs: ### pixi -To analyze pixi environments, specify the environment name prefixed with `pixi:` and point to the manifest file using `manifest-path`: +To analyze pixi environments, specify the environment name prefixed with `pixi:` and point to the manifest file using `manifest-path`. + +Any environment must pin the dependencies, which must be exact pins (i.e. `x.y.*` or `>=x.y.0,