Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand Down
14 changes: 12 additions & 2 deletions action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -28,6 +32,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}
Expand All @@ -37,5 +42,10 @@ runs:
POLICY_PATH: ${{ inputs.policy }}
ENVIRONMENT_PATHS: ${{ inputs.environment-paths }}
TODAY: ${{ inputs.today }}
MANIFEST_PATH: ${{ inputs.manifest-path }}
run: |
PYTHONPATH=${{github.action_path}} python -m minimum_versions validate --today="$TODAY" --policy="$POLICY_PATH" $ENVIRONMENT_PATHS
python -m minimum_versions validate \
--today="$TODAY" \
--policy="$POLICY_PATH" \
--manifest-path="$MANIFEST_PATH" \
$ENVIRONMENT_PATHS
25 changes: 25 additions & 0 deletions minimum_versions/environments/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import pathlib

from minimum_versions.environments.conda import parse_conda_environment
from minimum_versions.environments.pixi import parse_pixi_environment
from minimum_versions.environments.spec import Spec, compare_versions # noqa: F401

kinds = {
"conda": parse_conda_environment,
"pixi": parse_pixi_environment,
}


def parse_environment(specifier: str, manifest_path: pathlib.Path | None) -> list[Spec]:
split = specifier.split(":", maxsplit=1)
if len(split) == 1:
kind = "conda"
path = specifier
else:
kind, path = split

parser = kinds.get(kind)
if parser is None:
raise ValueError(f"Unknown kind {kind!r}, extracted from {specifier!r}.")

return parser(path, manifest_path)
46 changes: 46 additions & 0 deletions minimum_versions/environments/conda.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import pathlib

import yaml
from rattler import Version

from minimum_versions.environments.spec import Spec


def parse_spec(spec_text):
warnings = []
if ">" in spec_text or "<" in spec_text:
warnings.append(
f"package must be pinned with an exact version: {spec_text!r}."
" Using the version as an exact pin instead."
)

spec_text = spec_text.replace(">", "").replace("<", "")

if "=" in spec_text:
name, version_text = spec_text.split("=", maxsplit=1)
version = Version(version_text)
segments = version.segments()

if (len(segments) == 3 and segments[2] != [0]) or len(segments) > 3:
warnings.append(
f"package should be pinned to a minor version (got {version})"
)
else:
name = spec_text
version = None

return Spec(name, version), (name, warnings)


def parse_conda_environment(path: pathlib.Path, manifest_path: None):
env = yaml.safe_load(pathlib.Path(path).read_text())

specs = []
warnings = []
for dep in env["dependencies"]:
spec, warnings_ = parse_spec(dep)

specs.append(spec)
warnings.append(warnings_)

return specs, warnings
112 changes: 112 additions & 0 deletions minimum_versions/environments/pixi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
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>{_version_re})")
lower_pin_re = re.compile(rf">=(?P<version>{_version_re})$")
tight_pin_re = re.compile(rf">=(?P<lower>{_version_re}),<(?P<upper>{_version_re})")


def parse_spec(name, version_text):
# "*" => None
# "x.y.*" => "x.y"
# ">=x.y.0,<x.(y + 1).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 = []

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)

specs.append(spec)
warnings.append(warnings_)

return specs, warnings
26 changes: 26 additions & 0 deletions minimum_versions/environments/spec.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from dataclasses import dataclass

from rattler import Version


@dataclass
class Spec:
name: str
version: Version | None


def compare_versions(environments, policy_versions, ignored_violations):
status = {}
for env, specs in environments.items():
env_status = any(
(
spec.name not in ignored_violations
and (
spec.version is None
or spec.version > policy_versions[spec.name].version
)
)
for spec in specs
)
status[env] = env_status
return status
17 changes: 13 additions & 4 deletions minimum_versions/formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ">"
Expand All @@ -45,6 +47,7 @@ def format_bump_table(specs, policy_versions, releases, warnings, ignored_violat
">": Style(color="#ff0000", bold=True),
"=": Style(color="#008700", bold=True),
"<": Style(color="#d78700", bold=True),
"!": warning_style,
}

for spec in specs:
Expand All @@ -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:
Expand All @@ -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,
Expand Down
37 changes: 28 additions & 9 deletions minimum_versions/main.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import datetime
import os.path
import pathlib
import sys
from typing import Any

import rich_click as click
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from tlz.itertoolz import concat
from tlz.itertoolz import concat, unique

from minimum_versions.environments import compare_versions, parse_environment
from minimum_versions.formatting import format_bump_table
from minimum_versions.policy import find_policy_versions, parse_policy
from minimum_versions.release import fetch_releases
from minimum_versions.spec import compare_versions, parse_environment

click.rich_click.SHOW_ARGUMENTS = True

Expand All @@ -23,26 +25,39 @@ def parse_date(string):
return datetime.datetime.strptime(string, "%Y-%m-%d").date()


class _Path(click.Path):
def convert(
self, value: Any, param: click.Parameter | None, ctx: click.Context | None
) -> Any:
if not value:
return None

return super().convert(value, param, ctx)


@click.group()
def main():
pass


@main.command()
@click.argument(
"environment_paths",
type=click.Path(exists=True, readable=True, path_type=pathlib.Path),
nargs=-1,
@click.argument("environment_paths", type=str, nargs=-1)
@click.option(
"--manifest-path",
"manifest_path",
type=_Path(exists=True, path_type=pathlib.Path),
default=None,
)
@click.option("--today", type=parse_date, default=None)
@click.option("--policy", "policy_file", type=click.File(mode="r"), required=True)
def validate(today, policy_file, environment_paths):
def validate(today, policy_file, manifest_path, environment_paths):
console = Console()

policy = parse_policy(policy_file)

parsed_environments = {
path.stem: parse_environment(path.read_text()) for path in environment_paths
path.rsplit(os.path.sep, maxsplit=1)[-1]: parse_environment(path, manifest_path)
for path in environment_paths
}

warnings = {
Expand All @@ -54,7 +69,11 @@ def validate(today, policy_file, environment_paths):
}

all_packages = list(
dict.fromkeys(spec.name for spec in concat(environments.values()))
unique(
spec.name
for spec in concat(environments.values())
if spec.name not in policy.exclude
)
)

package_releases = fetch_releases(policy.channels, policy.platforms, all_packages)
Expand Down
Loading
Loading