From f2959bf6db3442707b8e1d40a206fb28067a9c91 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 29 Nov 2025 19:45:33 +0100 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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)