From e493acfe29cb0d9560dd320bc37c3ba0c6761819 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Fri, 19 Sep 2025 17:41:27 +0200 Subject: [PATCH 01/14] move the policy reading and validation to a separate module --- minimum_versions/policy.py | 116 +++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 minimum_versions/policy.py diff --git a/minimum_versions/policy.py b/minimum_versions/policy.py new file mode 100644 index 0000000..65d9bf9 --- /dev/null +++ b/minimum_versions/policy.py @@ -0,0 +1,116 @@ +import bisect +from dataclasses import dataclass, field + +import jsonschema +import yaml +from dateutil.relativedelta import relativedelta +from rattler import Version + +schema = { + "type": "object", + "properties": { + "channels": {"type": "array", "items": {"type": "string"}}, + "platforms": {"type": "array", "items": {"type": "string"}}, + "policy": { + "type": "object", + "properties": { + "packages": { + "type": "object", + "patternProperties": { + "^[a-z][-a-z_]*$": {"type": "integer", "minimum": 1} + }, + "additionalProperties": False, + }, + "default": {"type": "integer", "minimum": 1}, + "overrides": { + "type": "object", + "patternProperties": { + "^[a-z][-a-z_]*": {"type": "string", "format": "date"} + }, + "additionalProperties": False, + }, + "exclude": {"type": "array", "items": {"type": "string"}}, + "ignored_violations": { + "type": "array", + "items": {"type": "string", "pattern": "^[a-z][-a-z_]*$"}, + }, + }, + "required": [ + "packages", + "default", + "overrides", + "exclude", + "ignored_violations", + ], + }, + }, + "required": ["channels", "platforms", "policy"], +} + + +def find_release(releases, version): + index = bisect.bisect_left(releases, version, key=lambda x: x.version) + return releases[index] + + +def is_suitable_release(release): + if release.timestamp is None: + return False + + segments = release.version.extend_to_length(3).segments() + + return segments[2] == [0] + + +@dataclass +class Policy: + package_months: dict + default_months: int + + channels: list[str] = field(default_factory=list) + platforms: list[str] = field(default_factory=list) + + overrides: dict[str, Version] = field(default_factory=dict) + + ignored_violations: list[str] = field(default_factory=list) + exclude: list[str] = field(default_factory=list) + + def minimum_version(self, today, package_name, releases): + if (override := self.overrides.get(package_name)) is not None: + return find_release(releases, version=override) + + suitable_releases = [ + release for release in releases if is_suitable_release(release) + ] + + policy_months = self.package_months.get(package_name, self.default_months) + + cutoff_date = today - relativedelta(months=policy_months) + + index = bisect.bisect_left( + suitable_releases, cutoff_date, key=lambda x: x.timestamp.date() + ) + return suitable_releases[index - 1 if index > 0 else 0] + + +def parse_policy(f): + policy = yaml.safe_load(f) + + try: + jsonschema.validate(instance=policy, schema=schema) + except jsonschema.ValidationError as e: + raise jsonschema.ValidationError( + f"Invalid policy definition: {str(e)}" + ) from None + + package_policy = policy["policy"] + + return Policy( + channels=policy["channels"], + platforms=policy["platforms"], + exclude=package_policy["exclude"], + package_months=package_policy["packages"], + default_months=package_policy["default"], + ignored_violations=package_policy["ignored_violations"], + overrides=package_policy["overrides"], + ) From 9b2274a97cd066dbee6ac447df9e348aa490fb0f Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Fri, 19 Sep 2025 17:43:12 +0200 Subject: [PATCH 02/14] refactor the environment verification into a separate module --- minimum_versions/spec.py | 49 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 minimum_versions/spec.py diff --git a/minimum_versions/spec.py b/minimum_versions/spec.py new file mode 100644 index 0000000..8c7c7a7 --- /dev/null +++ b/minimum_versions/spec.py @@ -0,0 +1,49 @@ +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 From 785cc4d9ab100608218623306bb3c51ed32266da Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Fri, 19 Sep 2025 17:44:28 +0200 Subject: [PATCH 03/14] move the `Release` class into a separate module --- minimum_versions/release.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 minimum_versions/release.py diff --git a/minimum_versions/release.py b/minimum_versions/release.py new file mode 100644 index 0000000..82549f2 --- /dev/null +++ b/minimum_versions/release.py @@ -0,0 +1,19 @@ +import datetime +from dataclasses import dataclass, field + +from rattler import Version + + +@dataclass(order=True) +class Release: + version: Version + build_number: int + timestamp: datetime.datetime = field(compare=False) + + @classmethod + def from_repodata_record(cls, repo_data): + return cls( + version=repo_data.version, + build_number=repo_data.build_number, + timestamp=repo_data.timestamp, + ) From f39442523f8e10937e15473282ffa2606210af17 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Fri, 19 Sep 2025 17:53:16 +0200 Subject: [PATCH 04/14] add a function to fetch releases --- minimum_versions/release.py | 44 ++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/minimum_versions/release.py b/minimum_versions/release.py index 82549f2..654909f 100644 --- a/minimum_versions/release.py +++ b/minimum_versions/release.py @@ -1,7 +1,10 @@ +import asyncio import datetime from dataclasses import dataclass, field -from rattler import Version +from rattler import Gateway, Version +from tlz.functoolz import curry, pipe +from tlz.itertoolz import concat, groupby @dataclass(order=True) @@ -17,3 +20,42 @@ def from_repodata_record(cls, repo_data): build_number=repo_data.build_number, timestamp=repo_data.timestamp, ) + + +def group_packages(records): + groups = groupby(lambda r: r.name.normalized, records) + return { + name: sorted(map(Release.from_repodata_record, group)) + for name, group in groups.items() + } + + +def filter_releases(predicate, releases): + return { + name: [r for r in records if predicate(r)] for name, records in releases.items() + } + + +def deduplicate_releases(package_info): + def deduplicate(releases): + return min(releases, key=lambda p: p.timestamp) + + return { + name: list(map(deduplicate, groupby(lambda p: p.version, group).values())) + for name, group in package_info.items() + } + + +def fetch_releases(channels, platforms, all_packages): + gateway = Gateway() + + query = gateway.query(channels, platforms, all_packages, recursive=False) + records = asyncio.run(query) + + return pipe( + records, + concat, + group_packages, + curry(filter_releases, lambda r: r.timestamp is not None), + deduplicate_releases, + ) From 323d3508273a268929533ecbc2105f47539bb092 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Fri, 19 Sep 2025 18:23:13 +0200 Subject: [PATCH 05/14] remove the unused `is_preview` function --- minimum_versions.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/minimum_versions.py b/minimum_versions.py index f2bdaa2..060cd97 100644 --- a/minimum_versions.py +++ b/minimum_versions.py @@ -175,13 +175,6 @@ def parse_policy(file): ) -def is_preview(version): - candidates = {"rc", "b", "a"} - - *_, last_segment = version.segments() - return any(candidate in last_segment for candidate in candidates) - - def group_packages(records): groups = groupby(lambda r: r.name.normalized, records) return { From 691a2d60b2f80c65a4bebe430f4c02351aa77894 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Fri, 19 Sep 2025 20:20:19 +0200 Subject: [PATCH 06/14] implement a draft of the cli --- minimum_versions/formatting.py | 93 ++++++++++++++++++++++++++++++++++ minimum_versions/main.py | 91 +++++++++++++++++++++++++++++++++ minimum_versions/spec.py | 14 +++++ 3 files changed, 198 insertions(+) create mode 100644 minimum_versions/formatting.py create mode 100644 minimum_versions/main.py diff --git a/minimum_versions/formatting.py b/minimum_versions/formatting.py new file mode 100644 index 0000000..7c9c879 --- /dev/null +++ b/minimum_versions/formatting.py @@ -0,0 +1,93 @@ +import datetime + +from rich.style import Style +from rich.table import Column, Table + +from minimum_versions.release import Release + + +def lookup_spec_release(spec, releases): + version = spec.version.extend_to_length(3) + + compatible_versions = [ + release + for v, release in releases[spec.name].items() + if v.compatible_with(version) + ] + if not compatible_versions: + return Release(version="", build_number=0, timestamp=datetime.date(1970, 1, 1)) + + return compatible_versions[0] + + +def version_comparison_symbol(required, policy): + if required < policy: + return "<" + elif required > policy: + return ">" + else: + return "=" + + +def format_bump_table(specs, policy_versions, releases, warnings, ignored_violations): + table = Table( + Column("Package", width=20), + Column("Required", width=8), + "Required (date)", + Column("Policy", width=8), + "Policy (date)", + "Status", + ) + + heading_style = Style(color="#ff0000", bold=True) + warning_style = Style(color="#ffff00", bold=True) + styles = { + ">": Style(color="#ff0000", bold=True), + "=": Style(color="#008700", bold=True), + "<": Style(color="#d78700", bold=True), + } + + for spec in specs: + policy_release = policy_versions[spec.name] + policy_version = policy_release.version.with_segments(0, 2) + policy_date = policy_release.timestamp + + required_version = spec.version + required_date = lookup_spec_release(spec, releases).timestamp + + status = version_comparison_symbol(required_version, policy_version) + if status == ">" and spec.name in ignored_violations: + style = warning_style + else: + style = styles[status] + + table.add_row( + spec.name, + str(required_version), + f"{required_date:%Y-%m-%d}", + str(policy_version), + f"{policy_date:%Y-%m-%d}", + status, + style=style, + ) + + grid = Table.grid(expand=True, padding=(0, 2)) + grid.add_column(style=heading_style, vertical="middle") + grid.add_column() + grid.add_row("Version summary", table) + + if any(warnings.values()): + warning_table = Table(width=table.width, expand=True) + warning_table.add_column("Package") + warning_table.add_column("Warning") + + for package, messages in warnings.items(): + if not messages: + continue + warning_table.add_row(package, messages[0], style=warning_style) + for message in messages[1:]: + warning_table.add_row("", message, style=warning_style) + + grid.add_row("Warnings", warning_table) + + return grid diff --git a/minimum_versions/main.py b/minimum_versions/main.py new file mode 100644 index 0000000..ef96baa --- /dev/null +++ b/minimum_versions/main.py @@ -0,0 +1,91 @@ +import datetime +import pathlib +import sys + +import rich_click as click +from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from tlz.itertools import concat + +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 + + +def parse_date(string): + if not string: + return None + + return datetime.datetime.strptime(string, "%Y-%m-%d").date() + + +@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.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): + console = Console() + + policy = parse_policy(policy_file) + + parsed_environments = { + path.stem: parse_environment(path.read_text()) for path in environment_paths + } + + warnings = { + env: dict(warnings_) for env, (_, warnings_) in parsed_environments.items() + } + environments = { + env: [spec for spec in specs if spec.name not in policy.exclude] + for env, (specs, _) in parsed_environments.items() + } + + all_packages = list( + dict.fromkeys(spec.name for spec in concat(environments.values())) + ) + + package_releases = fetch_releases(policy.channels, policy.platforms, all_packages) + + if today is None: + today = datetime.date.today() + + policy_versions = find_policy_versions(package_releases, policy, today) + + status = compare_versions(environments, policy_versions, policy.ignored_violations) + + release_lookup = { + n: {r.version: r for r in releases} for n, releases in package_releases.items() + } + grids = { + env: format_bump_table( + specs, + policy_versions, + release_lookup, + warnings[env], + policy.ignored_violations, + ) + for env, specs in environments.items() + } + root_grid = Table.grid() + root_grid.add_column() + + for env, grid in grids.items(): + root_grid.add_row(Panel(grid, title=env, expand=True)) + + console.print(root_grid) + + status_code = 1 if any(status.values()) else 0 + sys.exit(status_code) diff --git a/minimum_versions/spec.py b/minimum_versions/spec.py index 8c7c7a7..a7c72d5 100644 --- a/minimum_versions/spec.py +++ b/minimum_versions/spec.py @@ -47,3 +47,17 @@ def parse_environment(text): 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 c088a54d3fd498d4073684eff3c14486d0524c15 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Fri, 19 Sep 2025 20:20:38 +0200 Subject: [PATCH 07/14] define `__main__` and `__init__` --- minimum_versions/__init__.py | 0 minimum_versions/__main__.py | 4 ++++ 2 files changed, 4 insertions(+) create mode 100644 minimum_versions/__init__.py create mode 100644 minimum_versions/__main__.py diff --git a/minimum_versions/__init__.py b/minimum_versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/minimum_versions/__main__.py b/minimum_versions/__main__.py new file mode 100644 index 0000000..a2e88bd --- /dev/null +++ b/minimum_versions/__main__.py @@ -0,0 +1,4 @@ +from minimum_versions.main import main + +if __name__ == "__main__": + main() From f7708650485d2f50a14ca8efd1315d596c65a8b9 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 20 Sep 2025 11:09:59 +0200 Subject: [PATCH 08/14] typo --- minimum_versions/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/minimum_versions/main.py b/minimum_versions/main.py index ef96baa..73e7250 100644 --- a/minimum_versions/main.py +++ b/minimum_versions/main.py @@ -6,7 +6,7 @@ from rich.console import Console from rich.panel import Panel from rich.table import Table -from tlz.itertools import concat +from tlz.itertoolz import concat from minimum_versions.formatting import format_bump_table from minimum_versions.policy import find_policy_versions, parse_policy From cefac9fac1493555149a0403abf2aa145e77a260 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 20 Sep 2025 11:10:08 +0200 Subject: [PATCH 09/14] implement the forgotten `find_policy_versions` --- minimum_versions/main.py | 2 +- minimum_versions/policy.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/minimum_versions/main.py b/minimum_versions/main.py index 73e7250..0f3ffc0 100644 --- a/minimum_versions/main.py +++ b/minimum_versions/main.py @@ -62,7 +62,7 @@ def validate(today, policy_file, environment_paths): if today is None: today = datetime.date.today() - policy_versions = find_policy_versions(package_releases, policy, today) + policy_versions = find_policy_versions(policy, today, package_releases) status = compare_versions(environments, policy_versions, policy.ignored_violations) diff --git a/minimum_versions/policy.py b/minimum_versions/policy.py index 65d9bf9..55db466 100644 --- a/minimum_versions/policy.py +++ b/minimum_versions/policy.py @@ -114,3 +114,10 @@ def parse_policy(f): ignored_violations=package_policy["ignored_violations"], overrides=package_policy["overrides"], ) + + +def find_policy_versions(policy, today, releases): + return { + name: policy.minimum_version(today, name, package_releases) + for name, package_releases in releases.items() + } From f587a3ee61c791669d4aa35015bc991b077c1aac Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 20 Sep 2025 11:11:15 +0200 Subject: [PATCH 10/14] remove the old script --- minimum_versions.py | 407 -------------------------------------------- 1 file changed, 407 deletions(-) delete mode 100644 minimum_versions.py diff --git a/minimum_versions.py b/minimum_versions.py deleted file mode 100644 index 060cd97..0000000 --- a/minimum_versions.py +++ /dev/null @@ -1,407 +0,0 @@ -import asyncio -import bisect -import datetime -import pathlib -import sys -from dataclasses import dataclass, field - -import jsonschema -import rich_click as click -import yaml -from dateutil.relativedelta import relativedelta -from rattler import Gateway, Version -from rich.console import Console -from rich.panel import Panel -from rich.style import Style -from rich.table import Column, Table -from tlz.functoolz import curry, pipe -from tlz.itertoolz import concat, groupby - -click.rich_click.SHOW_ARGUMENTS = True - - -schema = { - "type": "object", - "properties": { - "channels": {"type": "array", "items": {"type": "string"}}, - "platforms": {"type": "array", "items": {"type": "string"}}, - "policy": { - "type": "object", - "properties": { - "packages": { - "type": "object", - "patternProperties": { - "^[a-z][-a-z_]*$": {"type": "integer", "minimum": 1} - }, - "additionalProperties": False, - }, - "default": {"type": "integer", "minimum": 1}, - "overrides": { - "type": "object", - "patternProperties": { - "^[a-z][-a-z_]*": {"type": "string", "format": "date"} - }, - "additionalProperties": False, - }, - "exclude": {"type": "array", "items": {"type": "string"}}, - "ignored_violations": { - "type": "array", - "items": {"type": "string", "pattern": "^[a-z][-a-z_]*$"}, - }, - }, - "required": [ - "packages", - "default", - "overrides", - "exclude", - "ignored_violations", - ], - }, - }, - "required": ["channels", "platforms", "policy"], -} - - -@dataclass -class Policy: - package_months: dict - default_months: int - - channels: list[str] = field(default_factory=list) - platforms: list[str] = field(default_factory=list) - - overrides: dict[str, Version] = field(default_factory=dict) - - ignored_violations: list[str] = field(default_factory=list) - exclude: list[str] = field(default_factory=list) - - def minimum_version(self, today, package_name, releases): - if (override := self.overrides.get(package_name)) is not None: - return find_release(releases, version=override) - - suitable_releases = [ - release for release in releases if is_suitable_release(release) - ] - - policy_months = self.package_months.get(package_name, self.default_months) - - cutoff_date = today - relativedelta(months=policy_months) - - index = bisect.bisect_left( - suitable_releases, cutoff_date, key=lambda x: x.timestamp.date() - ) - return suitable_releases[index - 1 if index > 0 else 0] - - -@dataclass -class Spec: - name: str - version: Version | None - - @classmethod - def parse(cls, spec_text): - warnings = [] - if ">" in spec_text or "<" in spec_text: - warnings.append( - f"package must be pinned with an exact version: {spec_text!r}. Using the version as an exact pin instead." - ) - - spec_text = spec_text.replace(">", "").replace("<", "") - - if "=" in spec_text: - name, version_text = spec_text.split("=", maxsplit=1) - version = Version(version_text) - segments = version.segments() - - if (len(segments) == 3 and segments[2] != [0]) or len(segments) > 3: - warnings.append( - f"package should be pinned to a minor version (got {version})" - ) - else: - name = spec_text - version = None - - return cls(name, version), (name, warnings) - - -@dataclass(order=True) -class Release: - version: Version - build_number: int - timestamp: datetime.datetime = field(compare=False) - - @classmethod - def from_repodata_record(cls, repo_data): - return cls( - version=repo_data.version, - build_number=repo_data.build_number, - timestamp=repo_data.timestamp, - ) - - -def parse_environment(text): - env = yaml.safe_load(text) - - specs = [] - warnings = [] - for dep in env["dependencies"]: - spec, warnings_ = Spec.parse(dep) - - specs.append(spec) - warnings.append(warnings_) - - return specs, warnings - - -def parse_policy(file): - policy = yaml.safe_load(file) - try: - jsonschema.validate(instance=policy, schema=schema) - except jsonschema.ValidationError as e: - raise jsonschema.ValidationError( - f"Invalid policy definition: {str(e)}" - ) from None - - package_policy = policy["policy"] - - return Policy( - channels=policy["channels"], - platforms=policy["platforms"], - exclude=package_policy["exclude"], - package_months=package_policy["packages"], - default_months=package_policy["default"], - ignored_violations=package_policy["ignored_violations"], - overrides=package_policy["overrides"], - ) - - -def group_packages(records): - groups = groupby(lambda r: r.name.normalized, records) - return { - name: sorted(map(Release.from_repodata_record, group)) - for name, group in groups.items() - } - - -def filter_releases(predicate, releases): - return { - name: [r for r in records if predicate(r)] for name, records in releases.items() - } - - -def find_release(releases, version): - index = bisect.bisect_left(releases, version, key=lambda x: x.version) - return releases[index] - - -def deduplicate_releases(package_info): - def deduplicate(releases): - return min(releases, key=lambda p: p.timestamp) - - return { - name: list(map(deduplicate, groupby(lambda p: p.version, group).values())) - for name, group in package_info.items() - } - - -def find_policy_versions(policy, today, releases): - return { - name: policy.minimum_version(today, name, package_releases) - for name, package_releases in releases.items() - } - - -def is_suitable_release(release): - if release.timestamp is None: - return False - - segments = release.version.extend_to_length(3).segments() - - return segments[2] == [0] - - -def lookup_spec_release(spec, releases): - version = spec.version.extend_to_length(3) - - compatible_versions = [ - release - for v, release in releases[spec.name].items() - if v.compatible_with(version) - ] - if not compatible_versions: - return Release(version="", build_number=0, timestamp=datetime.date(1970, 1, 1)) - - return compatible_versions[0] - - -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 - - -def version_comparison_symbol(required, policy): - if required < policy: - return "<" - elif required > policy: - return ">" - else: - return "=" - - -def format_bump_table(specs, policy_versions, releases, warnings, ignored_violations): - table = Table( - Column("Package", width=20), - Column("Required", width=8), - "Required (date)", - Column("Policy", width=8), - "Policy (date)", - "Status", - ) - - heading_style = Style(color="#ff0000", bold=True) - warning_style = Style(color="#ffff00", bold=True) - styles = { - ">": Style(color="#ff0000", bold=True), - "=": Style(color="#008700", bold=True), - "<": Style(color="#d78700", bold=True), - } - - for spec in specs: - policy_release = policy_versions[spec.name] - policy_version = policy_release.version.with_segments(0, 2) - policy_date = policy_release.timestamp - - required_version = spec.version - required_date = lookup_spec_release(spec, releases).timestamp - - status = version_comparison_symbol(required_version, policy_version) - if status == ">" and spec.name in ignored_violations: - style = warning_style - else: - style = styles[status] - - table.add_row( - spec.name, - str(required_version), - f"{required_date:%Y-%m-%d}", - str(policy_version), - f"{policy_date:%Y-%m-%d}", - status, - style=style, - ) - - grid = Table.grid(expand=True, padding=(0, 2)) - grid.add_column(style=heading_style, vertical="middle") - grid.add_column() - grid.add_row("Version summary", table) - - if any(warnings.values()): - warning_table = Table(width=table.width, expand=True) - warning_table.add_column("Package") - warning_table.add_column("Warning") - - for package, messages in warnings.items(): - if not messages: - continue - warning_table.add_row(package, messages[0], style=warning_style) - for message in messages[1:]: - warning_table.add_row("", message, style=warning_style) - - grid.add_row("Warnings", warning_table) - - return grid - - -def parse_date(string): - if not string: - return None - - return datetime.datetime.strptime(string, "%Y-%m-%d").date() - - -@click.command() -@click.argument( - "environment_paths", - type=click.Path(exists=True, readable=True, path_type=pathlib.Path), - nargs=-1, -) -@click.option("--today", type=parse_date, default=None) -@click.option("--policy", "policy_file", type=click.File(mode="r"), required=True) -def main(today, policy_file, environment_paths): - console = Console() - - policy = parse_policy(policy_file) - - parsed_environments = { - path.stem: parse_environment(path.read_text()) for path in environment_paths - } - - warnings = { - env: dict(warnings_) for env, (_, warnings_) in parsed_environments.items() - } - environments = { - env: [spec for spec in specs if spec.name not in policy.exclude] - for env, (specs, _) in parsed_environments.items() - } - - all_packages = list( - dict.fromkeys(spec.name for spec in concat(environments.values())) - ) - - gateway = Gateway() - query = gateway.query( - policy.channels, policy.platforms, all_packages, recursive=False - ) - records = asyncio.run(query) - - if today is None: - today = datetime.date.today() - package_releases = pipe( - records, - concat, - group_packages, - curry(filter_releases, lambda r: r.timestamp is not None), - deduplicate_releases, - ) - policy_versions = pipe( - package_releases, - curry(find_policy_versions, policy, today), - ) - status = compare_versions(environments, policy_versions, policy.ignored_violations) - - release_lookup = { - n: {r.version: r for r in releases} for n, releases in package_releases.items() - } - grids = { - env: format_bump_table( - specs, - policy_versions, - release_lookup, - warnings[env], - policy.ignored_violations, - ) - for env, specs in environments.items() - } - root_grid = Table.grid() - root_grid.add_column() - - for env, grid in grids.items(): - root_grid.add_row(Panel(grid, title=env, expand=True)) - - console.print(root_grid) - - status_code = 1 if any(status.values()) else 0 - sys.exit(status_code) - - -if __name__ == "__main__": - main() From 0e5c4a616f072b9119e9ff1cdc5104889ced7d19 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 29 Nov 2025 19:29:51 +0100 Subject: [PATCH 11/14] try using `PYTHONPATH` to run the action --- action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yaml b/action.yaml index b355506..9380d7e 100644 --- a/action.yaml +++ b/action.yaml @@ -38,4 +38,4 @@ runs: ENVIRONMENT_PATHS: ${{ inputs.environment-paths }} TODAY: ${{ inputs.today }} run: | - python ${{ github.action_path }}/minimum_versions.py --today="$TODAY" --policy="$POLICY_PATH" $ENVIRONMENT_PATHS + PYTHONPATH=${{github.action_path}} python -m minimum_versions --today="$TODAY" --policy="$POLICY_PATH" $ENVIRONMENT_PATHS From 746ab516570bc34646f56ad79d90d1bc4b0984bc Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 29 Nov 2025 19:30:55 +0100 Subject: [PATCH 12/14] select validation --- action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yaml b/action.yaml index 9380d7e..82a9fdc 100644 --- a/action.yaml +++ b/action.yaml @@ -38,4 +38,4 @@ runs: ENVIRONMENT_PATHS: ${{ inputs.environment-paths }} TODAY: ${{ inputs.today }} run: | - PYTHONPATH=${{github.action_path}} python -m minimum_versions --today="$TODAY" --policy="$POLICY_PATH" $ENVIRONMENT_PATHS + PYTHONPATH=${{github.action_path}} python -m minimum_versions validate --today="$TODAY" --policy="$POLICY_PATH" $ENVIRONMENT_PATHS From 06e6e05e888894a94599734345f2931e6cf01385 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 29 Nov 2025 19:32:37 +0100 Subject: [PATCH 13/14] change the unit tests --- test_script.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test_script.py b/test_script.py index 22a62f9..929f796 100644 --- a/test_script.py +++ b/test_script.py @@ -3,7 +3,9 @@ import pytest from rattler import Version -from minimum_versions import Policy, Release, Spec +from minimum_versions.policy import Policy +from minimum_versions.release import Release +from minimum_versions.spec import Spec @pytest.mark.parametrize( From f983e74310948607c8dae48da01621c886c90d92 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 29 Nov 2025 19:36:18 +0100 Subject: [PATCH 14/14] replace the script test with multiple test modules --- .../tests/test_policy.py | 22 ---------------- minimum_versions/tests/test_spec.py | 25 +++++++++++++++++++ 2 files changed, 25 insertions(+), 22 deletions(-) rename test_script.py => minimum_versions/tests/test_policy.py (70%) create mode 100644 minimum_versions/tests/test_spec.py diff --git a/test_script.py b/minimum_versions/tests/test_policy.py similarity index 70% rename from test_script.py rename to minimum_versions/tests/test_policy.py index 929f796..bc2ab96 100644 --- a/test_script.py +++ b/minimum_versions/tests/test_policy.py @@ -5,28 +5,6 @@ from minimum_versions.policy import Policy from minimum_versions.release import Release -from minimum_versions.spec import Spec - - -@pytest.mark.parametrize( - ["text", "expected_spec", "expected_name", "expected_warnings"], - ( - ("numpy=1.23", Spec("numpy", Version("1.23")), "numpy", []), - ("xarray=2024.10.0", Spec("xarray", Version("2024.10.0")), "xarray", []), - ( - "xarray=2024.10.1", - Spec("xarray", Version("2024.10.1")), - "xarray", - ["package should be pinned to a minor version (got 2024.10.1)"], - ), - ), -) -def test_spec_parse(text, expected_spec, expected_name, expected_warnings): - actual_spec, (actual_name, actual_warnings) = Spec.parse(text) - - assert actual_spec == expected_spec - assert actual_name == expected_name - assert actual_warnings == expected_warnings @pytest.mark.parametrize( diff --git a/minimum_versions/tests/test_spec.py b/minimum_versions/tests/test_spec.py new file mode 100644 index 0000000..64b4252 --- /dev/null +++ b/minimum_versions/tests/test_spec.py @@ -0,0 +1,25 @@ +import pytest +from rattler import Version + +from minimum_versions.spec import Spec + + +@pytest.mark.parametrize( + ["text", "expected_spec", "expected_name", "expected_warnings"], + ( + ("numpy=1.23", Spec("numpy", Version("1.23")), "numpy", []), + ("xarray=2024.10.0", Spec("xarray", Version("2024.10.0")), "xarray", []), + ( + "xarray=2024.10.1", + Spec("xarray", Version("2024.10.1")), + "xarray", + ["package should be pinned to a minor version (got 2024.10.1)"], + ), + ), +) +def test_spec_parse(text, expected_spec, expected_name, expected_warnings): + actual_spec, (actual_name, actual_warnings) = Spec.parse(text) + + assert actual_spec == expected_spec + assert actual_name == expected_name + assert actual_warnings == expected_warnings