From 9cb4647f27c4ec9ebfb68b67984b41e5ff4826ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Sep 2025 08:59:05 +0200 Subject: [PATCH 1/9] Bump actions/setup-python from 5 to 6 (#31) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/setup-python dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .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 0d1e9e5..c911e61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: - name: clone the repository uses: actions/checkout@v5 - name: setup python - uses: actions/setup-python@v5 + uses: actions/setup-python@v6 with: python-version: "${{ matrix.python-version }}" - name: upgrade pip From 1db091c79eecda4c17db0ac6d98cc7dab24da613 Mon Sep 17 00:00:00 2001 From: Mathias Hauser Date: Wed, 10 Sep 2025 17:13:30 +0200 Subject: [PATCH 2/9] document empty `overrides`, `exclude` and `ignored_validations` (#33) * document optional `overrides`, `exclude` and `ignored_validations` * add test * Apply suggestions from code review Co-authored-by: Justus Magin --------- Co-authored-by: Justus Magin --- .github/workflows/ci.yml | 8 +++++++- README.md | 12 +++++++++++- policy_no_extra_options.yaml | 14 ++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 policy_no_extra_options.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c911e61..09197c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,14 +50,20 @@ jobs: envs/env1.yaml envs/env2.yaml expected-failure: ["false"] + policy-file: ["policy.yaml"] include: - env-paths: | envs/failing-env1.yaml + policy-file: "policy.yaml" expected-failure: "true" - env-paths: | envs/env1.yaml envs/failing-env1.yaml + policy-file: "policy.yaml" expected-failure: "true" + - env-paths: "envs/env1.yaml" + policy-file: policy_no_extra_options.yaml + expected-failure: "false" steps: - name: clone the repository @@ -67,7 +73,7 @@ jobs: id: action-run continue-on-error: true with: - policy: policy.yaml + policy: ${{ matrix.policy-file }} environment-paths: ${{ matrix.env-paths }} today: 2024-12-20 - name: detect outcome diff --git a/README.md b/README.md index 69ac401..62b3fba 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,17 @@ policy: - package4 ``` -then add a new step to CI: +If there are no packages with `overrides`, `exclude`, or `ignored_violations`, you can set +them to an empty mapping or sequence, respectively: + +```yaml + ... + overrides: {} + exclude: [] + ignored_violations: [] +``` + +Then add a new step to CI: ```yaml jobs: diff --git a/policy_no_extra_options.yaml b/policy_no_extra_options.yaml new file mode 100644 index 0000000..b89a03f --- /dev/null +++ b/policy_no_extra_options.yaml @@ -0,0 +1,14 @@ +channels: + - conda-forge +platforms: + - noarch + - linux-64 +policy: + # all packages in months + packages: + python: 30 + numpy: 18 + default: 12 + overrides: {} + exclude: [] + ignored_violations: [] From 96bbc856dda744d9185cf68607df9954a4859b26 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 8 Oct 2025 07:51:02 +0200 Subject: [PATCH 3/9] [pre-commit.ci] pre-commit autoupdate (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - https://github.com/psf/black → https://github.com/psf/black-pre-commit-mirror - [github.com/psf/black-pre-commit-mirror: 25.1.0 → 25.9.0](https://github.com/psf/black-pre-commit-mirror/compare/25.1.0...25.9.0) - [github.com/astral-sh/ruff-pre-commit: v0.12.11 → v0.13.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.11...v0.13.3) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 354db93..1dc44bd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,12 +4,12 @@ repos: hooks: - id: trailing-whitespace - id: end-of-file-fixer - - repo: https://github.com/psf/black - rev: 25.1.0 + - repo: https://github.com/psf/black-pre-commit-mirror + rev: 25.9.0 hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.11 + rev: v0.13.3 hooks: - id: ruff args: ["--fix"] From c6916dcc94d69ee9cefb396bf48507f818f9e1ef Mon Sep 17 00:00:00 2001 From: Mathias Hauser Date: Wed, 22 Oct 2025 19:35:50 +0200 Subject: [PATCH 4/9] widen package regex (#36) * widen package regex * disallow uppercase names again * Update minimum_versions.py --- minimum_versions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/minimum_versions.py b/minimum_versions.py index f2bdaa2..2565515 100644 --- a/minimum_versions.py +++ b/minimum_versions.py @@ -31,7 +31,7 @@ "packages": { "type": "object", "patternProperties": { - "^[a-z][-a-z_]*$": {"type": "integer", "minimum": 1} + "^[a-z][a-z0-9_-]*$": {"type": "integer", "minimum": 1} }, "additionalProperties": False, }, @@ -39,14 +39,14 @@ "overrides": { "type": "object", "patternProperties": { - "^[a-z][-a-z_]*": {"type": "string", "format": "date"} + "^[a-z][a-z0-9_-]*": {"type": "string", "format": "date"} }, "additionalProperties": False, }, "exclude": {"type": "array", "items": {"type": "string"}}, "ignored_violations": { "type": "array", - "items": {"type": "string", "pattern": "^[a-z][-a-z_]*$"}, + "items": {"type": "string", "pattern": r"^[a-z][a-z0-9_-]*$"}, }, }, "required": [ From 4ce45dae6761305d6f5c3e02e2ab6d0b2e82fc45 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 09:07:26 +0100 Subject: [PATCH 5/9] [pre-commit.ci] pre-commit autoupdate (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.13.3 → v0.14.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.13.3...v0.14.3) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1dc44bd..b3e417b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,7 +9,7 @@ repos: hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.13.3 + rev: v0.14.3 hooks: - id: ruff args: ["--fix"] From 85bd465e90b961b5cc3cc9660823fc67aee17640 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Nov 2025 09:30:58 +0100 Subject: [PATCH 6/9] Bump actions/checkout from 5 to 6 (#40) Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09197c2..0be3c0f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: steps: - name: clone the repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: setup python uses: actions/setup-python@v6 with: @@ -67,7 +67,7 @@ jobs: steps: - name: clone the repository - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: run action uses: ./ id: action-run From 64243c78d5b4ef2fd6826e3e06b9dcc5e7dc4286 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Sat, 29 Nov 2025 19:37:57 +0100 Subject: [PATCH 7/9] refactor the script into modules (#41) * move the policy reading and validation to a separate module * refactor the environment verification into a separate module * move the `Release` class into a separate module * add a function to fetch releases * remove the unused `is_preview` function * implement a draft of the cli * define `__main__` and `__init__` * typo * implement the forgotten `find_policy_versions` * remove the old script * try using `PYTHONPATH` to run the action * select validation * change the unit tests * replace the script test with multiple test modules --- action.yaml | 2 +- minimum_versions.py | 414 ------------------ minimum_versions/__init__.py | 0 minimum_versions/__main__.py | 4 + minimum_versions/formatting.py | 93 ++++ minimum_versions/main.py | 91 ++++ minimum_versions/policy.py | 123 ++++++ minimum_versions/release.py | 61 +++ minimum_versions/spec.py | 63 +++ .../tests/test_policy.py | 24 +- minimum_versions/tests/test_spec.py | 25 ++ 11 files changed, 463 insertions(+), 437 deletions(-) delete mode 100644 minimum_versions.py create mode 100644 minimum_versions/__init__.py create mode 100644 minimum_versions/__main__.py create mode 100644 minimum_versions/formatting.py create mode 100644 minimum_versions/main.py create mode 100644 minimum_versions/policy.py create mode 100644 minimum_versions/release.py create mode 100644 minimum_versions/spec.py rename test_script.py => minimum_versions/tests/test_policy.py (69%) create mode 100644 minimum_versions/tests/test_spec.py diff --git a/action.yaml b/action.yaml index b355506..82a9fdc 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 validate --today="$TODAY" --policy="$POLICY_PATH" $ENVIRONMENT_PATHS diff --git a/minimum_versions.py b/minimum_versions.py deleted file mode 100644 index 2565515..0000000 --- a/minimum_versions.py +++ /dev/null @@ -1,414 +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-z0-9_-]*$": {"type": "integer", "minimum": 1} - }, - "additionalProperties": False, - }, - "default": {"type": "integer", "minimum": 1}, - "overrides": { - "type": "object", - "patternProperties": { - "^[a-z][a-z0-9_-]*": {"type": "string", "format": "date"} - }, - "additionalProperties": False, - }, - "exclude": {"type": "array", "items": {"type": "string"}}, - "ignored_violations": { - "type": "array", - "items": {"type": "string", "pattern": r"^[a-z][a-z0-9_-]*$"}, - }, - }, - "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 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 { - 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() 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() 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..0f3ffc0 --- /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.itertoolz 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(policy, today, package_releases) + + 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/policy.py b/minimum_versions/policy.py new file mode 100644 index 0000000..3d25bf2 --- /dev/null +++ b/minimum_versions/policy.py @@ -0,0 +1,123 @@ +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-z0-9_-]*$": {"type": "integer", "minimum": 1} + }, + "additionalProperties": False, + }, + "default": {"type": "integer", "minimum": 1}, + "overrides": { + "type": "object", + "patternProperties": { + "^[a-z][a-z0-9_-]*": {"type": "string", "format": "date"} + }, + "additionalProperties": False, + }, + "exclude": {"type": "array", "items": {"type": "string"}}, + "ignored_violations": { + "type": "array", + "items": {"type": "string", "pattern": "^[a-z][a-z0-9_-]*$"}, + }, + }, + "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"], + ) + + +def find_policy_versions(policy, today, releases): + return { + name: policy.minimum_version(today, name, package_releases) + for name, package_releases in releases.items() + } diff --git a/minimum_versions/release.py b/minimum_versions/release.py new file mode 100644 index 0000000..654909f --- /dev/null +++ b/minimum_versions/release.py @@ -0,0 +1,61 @@ +import asyncio +import datetime +from dataclasses import dataclass, field + +from rattler import Gateway, Version +from tlz.functoolz import curry, pipe +from tlz.itertoolz import concat, groupby + + +@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 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, + ) diff --git a/minimum_versions/spec.py b/minimum_versions/spec.py new file mode 100644 index 0000000..a7c72d5 --- /dev/null +++ b/minimum_versions/spec.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass + +import yaml +from rattler import Version + + +@dataclass +class Spec: + name: str + version: Version | None + + @classmethod + def parse(cls, spec_text): + warnings = [] + if ">" in spec_text or "<" in spec_text: + warnings.append( + f"package must be pinned with an exact version: {spec_text!r}. Using the version as an exact pin instead." + ) + + spec_text = spec_text.replace(">", "").replace("<", "") + + if "=" in spec_text: + name, version_text = spec_text.split("=", maxsplit=1) + version = Version(version_text) + segments = version.segments() + + if (len(segments) == 3 and segments[2] != [0]) or len(segments) > 3: + warnings.append( + f"package should be pinned to a minor version (got {version})" + ) + else: + name = spec_text + version = None + + return cls(name, version), (name, warnings) + + +def parse_environment(text): + env = yaml.safe_load(text) + + specs = [] + warnings = [] + for dep in env["dependencies"]: + spec, warnings_ = Spec.parse(dep) + + specs.append(spec) + warnings.append(warnings_) + + return specs, warnings + + +def compare_versions(environments, policy_versions, ignored_violations): + status = {} + for env, specs in environments.items(): + env_status = any( + ( + spec.name not in ignored_violations + and spec.version > policy_versions[spec.name].version + ) + for spec in specs + ) + status[env] = env_status + return status diff --git a/test_script.py b/minimum_versions/tests/test_policy.py similarity index 69% rename from test_script.py rename to minimum_versions/tests/test_policy.py index 22a62f9..bc2ab96 100644 --- a/test_script.py +++ b/minimum_versions/tests/test_policy.py @@ -3,28 +3,8 @@ import pytest from rattler import Version -from minimum_versions import Policy, Release, 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 +from minimum_versions.policy import Policy +from minimum_versions.release import Release @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 From 249d38f10d18acf5958d51d6590b382137140ab3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 19:58:35 +0100 Subject: [PATCH 8/9] [pre-commit.ci] pre-commit autoupdate (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black-pre-commit-mirror: 25.9.0 → 25.11.0](https://github.com/psf/black-pre-commit-mirror/compare/25.9.0...25.11.0) - [github.com/astral-sh/ruff-pre-commit: v0.14.3 → v0.14.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.14.3...v0.14.7) - [github.com/rbubley/mirrors-prettier: v3.6.2 → v3.7.3](https://github.com/rbubley/mirrors-prettier/compare/v3.6.2...v3.7.3) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b3e417b..e9276e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,16 +5,16 @@ repos: - id: trailing-whitespace - id: end-of-file-fixer - repo: https://github.com/psf/black-pre-commit-mirror - rev: 25.9.0 + rev: 25.11.0 hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.14.3 + rev: v0.14.7 hooks: - id: ruff args: ["--fix"] - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.6.2 + rev: v3.7.3 hooks: - id: prettier args: ["--cache-location=.prettier_cache"] From 3db8e1c17328ee1e27dfe4db90d908644856eb61 Mon Sep 17 00:00:00 2001 From: Justus Magin Date: Wed, 10 Dec 2025 21:17:23 +0100 Subject: [PATCH 9/9] support pixi environments (#42) * refactor `spec` into a separate submodule * dispatch by kind * default the kind to `conda` * adapt the tests * configure project metadata * add `manifest_path` to the cli options * formatting * write the environment parsing for pixi envs * raise a more descriptive error if no suitable releases were found * filter out excluded packages before fetching releases * consider unpinned specs as not matching * gracefully handle unpinned but not ignored dependencies * install the module instead of modifying `sys.path` * allow passing `manifest-path` to the action * misformatted input definition * stop testing on the unsupported python 3.10 * install the package itself * use the warning style for unpinned versions * warn about ignored PyPI dependencies * tests for most of the functions in `minimum_versions.release` * tests for the environment functions * tests for parsing conda specs * also check parsing the entire conda env * rename * check parsing pixi specs * fix a bug in the lower pin regex * additional spec parsing checks * check invalid versions raise * checks for parse_pixi_environment * include the default feature in the features * rename the `environment-paths` input to `environments` * describe how to analyze `pixi` environments * rename `env-paths` to `envs` * e2e tests for pixi and mixed envs * typo * add policy files for the pixi tests * another typo * quotes and expected failure settings * add a failing pixi env * also check the default pypi-dependencies * back to `python=3.9` * support the `no-default-feature` option * support analyzing missing features * change the error text for unknown version specs * add a note containing the package name * add more information * support dict pins * configure coverage * check the format detection * raise on unknown features * support no features * check that `<=` is also detected * check pypi dependencies * support local packages * skip the local package, if any * properly check that local packages are skipped * add information about the environments --- .github/workflows/ci.yml | 36 +- README.md | 69 +++- action.yaml | 26 +- envs/pixi.toml | 17 + minimum_versions/environments/__init__.py | 25 ++ minimum_versions/environments/conda.py | 46 +++ minimum_versions/environments/pixi.py | 152 ++++++++ minimum_versions/environments/spec.py | 26 ++ minimum_versions/formatting.py | 17 +- minimum_versions/main.py | 37 +- minimum_versions/policy.py | 3 + minimum_versions/spec.py | 63 ---- minimum_versions/tests/test_environments.py | 392 ++++++++++++++++++++ minimum_versions/tests/test_release.py | 112 ++++++ minimum_versions/tests/test_spec.py | 6 +- pyproject.toml | 45 +++ 16 files changed, 975 insertions(+), 97 deletions(-) create mode 100644 envs/pixi.toml create mode 100644 minimum_versions/environments/__init__.py create mode 100644 minimum_versions/environments/conda.py create mode 100644 minimum_versions/environments/pixi.py create mode 100644 minimum_versions/environments/spec.py delete mode 100644 minimum_versions/spec.py create mode 100644 minimum_versions/tests/test_environments.py create mode 100644 minimum_versions/tests/test_release.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0be3c0f..42a5162 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] steps: - name: clone the repository @@ -31,6 +31,7 @@ jobs: - name: install dependencies run: | python -m pip install -r requirements.txt + python -m pip install . python -m pip install pytest - name: run tests run: | @@ -43,7 +44,7 @@ jobs: strategy: fail-fast: false matrix: - env-paths: + envs: - "envs/env1.yaml" - "envs/env2.yaml" - | @@ -52,18 +53,38 @@ jobs: expected-failure: ["false"] policy-file: ["policy.yaml"] include: - - env-paths: | + - envs: | envs/failing-env1.yaml policy-file: "policy.yaml" expected-failure: "true" - - env-paths: | + - envs: | envs/env1.yaml envs/failing-env1.yaml policy-file: "policy.yaml" expected-failure: "true" - - env-paths: "envs/env1.yaml" - policy-file: policy_no_extra_options.yaml + - envs: "envs/env1.yaml" + policy-file: "policy_no_extra_options.yaml" expected-failure: "false" + - envs: "pixi:env1" + manifest-path: "envs/pixi.toml" + policy-file: "policy.yaml" + expected-failure: "false" + - envs: | + pixi:env1 + pixi:env2 + manifest-path: "envs/pixi.toml" + policy-file: "policy.yaml" + expected-failure: "false" + - envs: | + pixi:env1 + conda:envs/env2.yaml + manifest-path: "envs/pixi.toml" + policy-file: "policy.yaml" + expected-failure: "false" + - envs: "pixi:failing-env" + manifest-path: "envs/pixi.toml" + policy-file: "policy.yaml" + expected-failure: "true" steps: - name: clone the repository @@ -74,8 +95,9 @@ jobs: continue-on-error: true with: policy: ${{ matrix.policy-file }} - environment-paths: ${{ matrix.env-paths }} + environments: ${{ matrix.envs }} today: 2024-12-20 + manifest-path: ${{ matrix.manifest-path }} - name: detect outcome if: always() shell: bash -l {0} diff --git a/README.md b/README.md index 62b3fba..6c1a61f 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,13 @@ 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. + +The conda environment file _must_ specify exactly the `conda-forge` channel. ```yaml jobs: @@ -53,7 +59,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 +73,63 @@ 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`. + +Any environment must pin the dependencies, which must be exact pins (i.e. `x.y.*` or `>=x.y.0,- + Time machine for testing + required: false + type: string + environments: description: >- - The paths to the environment files + The names or paths of the environments. Pixi environment names must be + prefixed with `pixi:`. Conda environment paths may be prefixed with + `conda:`. If there is no prefix, it is assumed to be a conda env path. required: true type: list - today: + manifest-path: description: >- - Time machine for testing + Path to the manifest file of `pixi`. Required for `pixi` environments. required: false type: string outputs: {} - runs: using: "composite" @@ -28,6 +34,7 @@ runs: run: | echo "::group::Install dependencies" python -m pip install -r ${{ github.action_path }}/requirements.txt + python -m pip install ${{ github.action_path }} echo "::endgroup::" - name: analyze environments shell: bash -l {0} @@ -35,7 +42,12 @@ runs: COLUMNS: 120 FORCE_COLOR: 3 POLICY_PATH: ${{ inputs.policy }} - ENVIRONMENT_PATHS: ${{ inputs.environment-paths }} + ENVIRONMENTS: ${{ inputs.environments }} TODAY: ${{ inputs.today }} + MANIFEST_PATH: ${{ inputs.manifest-path }} run: | - PYTHONPATH=${{github.action_path}} python -m minimum_versions validate --today="$TODAY" --policy="$POLICY_PATH" $ENVIRONMENT_PATHS + python -m minimum_versions validate \ + --today="$TODAY" \ + --policy="$POLICY_PATH" \ + --manifest-path="$MANIFEST_PATH" \ + $ENVIRONMENTS diff --git a/envs/pixi.toml b/envs/pixi.toml new file mode 100644 index 0000000..365e095 --- /dev/null +++ b/envs/pixi.toml @@ -0,0 +1,17 @@ +[dependencies] +pandas = "2.1" +packaging = "23.1" + +[feature.py39.dependencies] +python = "3.9" + +[feature.py310.dependencies] +python = "3.10" + +[feature.failing.dependencies] +numpy = "2.1" + +[environments] +env1 = { features = ["py310"] } +env2 = ["py39"] +failing-env = { features = ["failing"] } diff --git a/minimum_versions/environments/__init__.py b/minimum_versions/environments/__init__.py new file mode 100644 index 0000000..a4df9d1 --- /dev/null +++ b/minimum_versions/environments/__init__.py @@ -0,0 +1,25 @@ +import pathlib + +from minimum_versions.environments.conda import parse_conda_environment +from minimum_versions.environments.pixi import parse_pixi_environment +from minimum_versions.environments.spec import Spec, compare_versions # noqa: F401 + +kinds = { + "conda": parse_conda_environment, + "pixi": parse_pixi_environment, +} + + +def parse_environment(specifier: str, manifest_path: pathlib.Path | None) -> list[Spec]: + split = specifier.split(":", maxsplit=1) + if len(split) == 1: + kind = "conda" + path = specifier + else: + kind, path = split + + parser = kinds.get(kind) + if parser is None: + raise ValueError(f"Unknown kind {kind!r}, extracted from {specifier!r}.") + + return parser(path, manifest_path) diff --git a/minimum_versions/environments/conda.py b/minimum_versions/environments/conda.py new file mode 100644 index 0000000..27dcfc0 --- /dev/null +++ b/minimum_versions/environments/conda.py @@ -0,0 +1,46 @@ +import pathlib + +import yaml +from rattler import Version + +from minimum_versions.environments.spec import Spec + + +def parse_spec(spec_text): + warnings = [] + if ">" in spec_text or "<" in spec_text: + warnings.append( + f"package must be pinned with an exact version: {spec_text!r}." + " Using the version as an exact pin instead." + ) + + spec_text = spec_text.replace(">", "").replace("<", "") + + if "=" in spec_text: + name, version_text = spec_text.split("=", maxsplit=1) + version = Version(version_text) + segments = version.segments() + + if (len(segments) == 3 and segments[2] != [0]) or len(segments) > 3: + warnings.append( + f"package should be pinned to a minor version (got {version})" + ) + else: + name = spec_text + version = None + + return Spec(name, version), (name, warnings) + + +def parse_conda_environment(path: pathlib.Path, manifest_path: None): + env = yaml.safe_load(pathlib.Path(path).read_text()) + + specs = [] + warnings = [] + for dep in env["dependencies"]: + spec, warnings_ = parse_spec(dep) + + specs.append(spec) + warnings.append(warnings_) + + return specs, warnings diff --git a/minimum_versions/environments/pixi.py b/minimum_versions/environments/pixi.py new file mode 100644 index 0000000..60e634a --- /dev/null +++ b/minimum_versions/environments/pixi.py @@ -0,0 +1,152 @@ +import pathlib +import re + +import tomllib +from rattler import Version +from tlz.dicttoolz import get_in, merge + +from minimum_versions.environments.spec import Spec + +_version_re = r"[0-9]+\.[0-9]+(?:\.[0-9]+|\.\*)?" +version_re = re.compile(f"(?P{_version_re})") +lower_pin_re = re.compile(rf">=(?P{_version_re})$") +tight_pin_re = re.compile(rf">=(?P{_version_re}),<(?P{_version_re})") + + +def parse_spec(name, version_text: str | dict): + # "*" => None + # "x.y.*" => "x.y" + # ">=x.y.0, "x.y" (+ warning) + # ">=x.y.*" => "x.y" (+ warning) + + if isinstance(version_text, dict): + version_text = version_text.get("version", "*") + + warnings = [] + if version_text == "*": + raw_version = None + elif (match := version_re.match(version_text)) is not None: + raw_version = match.group("version") + elif (match := lower_pin_re.match(version_text)) is not None: + warnings.append( + f"package must be pinned with an exact version: {version_text!r}." + " Using the version as an exact pin instead." + ) + + raw_version = match.group("version") + elif (match := tight_pin_re.match(version_text)) is not None: + lower_pin = match.group("lower") + upper_pin = match.group("upper") + + warnings.append( + f"lower pin {lower_pin!r} and upper pin {upper_pin!r} found." + " Using the lower pin for now, please convert to the standard x.y.* syntax." + ) + + raw_version = lower_pin + else: + raise ValueError(f"Unsupported version spec: {version_text}") + + if raw_version is not None: + version = Version(raw_version.removesuffix(".*")) + segments = version.segments() + if (len(segments) == 3 and segments[2] != [0]) or len(segments) > 3: + warnings.append( + f"package should be pinned to a minor version (got {version})" + ) + else: + version = raw_version + + return Spec(name, version), (name, warnings) + + +def parse_pixi_environment(name: str, manifest_path: pathlib.Path | None): + if manifest_path is None: + raise ValueError("--manifest-path is required for pixi environments.") + + with manifest_path.open(mode="rb") as f: + data = tomllib.load(f) + + if manifest_path.name == "pyproject.toml": + pixi_config = get_in(["tool", "pixi"], data, None) + if pixi_config is None: + raise ValueError( + f"The 'tool.pixi' section is missing from {manifest_path}." + ) + else: + pixi_config = data + + environment_definitions = pixi_config.get("environments") + if environment_definitions is None: + raise ValueError("Can't find environments in the pixi config.") + + all_features = pixi_config.get("feature", {}) + + env = environment_definitions.get(name) + if env is None: + raise ValueError(f"Unknown environment: {name}") + + if isinstance(env, list): + feature_names = env + elif isinstance(env, dict) and env.keys() - {"features", "no-default-feature"}: + raise ValueError( + "Options other than 'features' and 'no-default-feature'" + f" are not supported. Got {env}." + ) + elif isinstance(env, dict): + feature_names = env["features"] + if not env.get("no-default-feature", False): + feature_names.insert(0, "default") + else: + raise ValueError("unexpected environment type") + + unknown_features = [ + name for name in feature_names if name != "default" and name not in all_features + ] + if unknown_features: + raise ValueError(f"unknown features: {', '.join(unknown_features)}") + + features = [ + ( + get_in([feature, "dependencies"], all_features, {}) + if feature != "default" + else pixi_config.get("dependencies", []) + ) + for feature in feature_names + ] + + local_package_name = get_in(["package", "name"], pixi_config, None) + pins = { + name: pin + for name, pin in merge(features).items() + # skip the local package, if any + if name != local_package_name + } + + specs = [] + warnings = [] + + pypi_dependencies = { + feature: ( + get_in([feature, "pypi-dependencies"], all_features) + if feature != "default" + else pixi_config.get("pypi-dependencies", []) + ) + for feature in feature_names + } + with_pypi_dependencies = { + feature: bool(deps) for feature, deps in pypi_dependencies.items() if deps + } + for feature in with_pypi_dependencies: + warnings.append((f"feature:{feature}", ["Ignored PyPI dependencies."])) + for package_name, pin in pins.items(): + try: + spec, warnings_ = parse_spec(package_name, pin) + except ValueError as e: + e.add_note(f"environment {name}: {package_name}{pin}") + raise + + specs.append(spec) + warnings.append(warnings_) + + return specs, warnings diff --git a/minimum_versions/environments/spec.py b/minimum_versions/environments/spec.py new file mode 100644 index 0000000..f93fa94 --- /dev/null +++ b/minimum_versions/environments/spec.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass + +from rattler import Version + + +@dataclass +class Spec: + name: str + version: Version | None + + +def compare_versions(environments, policy_versions, ignored_violations): + status = {} + for env, specs in environments.items(): + env_status = any( + ( + spec.name not in ignored_violations + and ( + spec.version is None + or spec.version > policy_versions[spec.name].version + ) + ) + for spec in specs + ) + status[env] = env_status + return status diff --git a/minimum_versions/formatting.py b/minimum_versions/formatting.py index 7c9c879..ba6fca9 100644 --- a/minimum_versions/formatting.py +++ b/minimum_versions/formatting.py @@ -21,7 +21,9 @@ def lookup_spec_release(spec, releases): def version_comparison_symbol(required, policy): - if required < policy: + if required is None: + return "!" + elif required < policy: return "<" elif required > policy: return ">" @@ -45,6 +47,7 @@ def format_bump_table(specs, policy_versions, releases, warnings, ignored_violat ">": Style(color="#ff0000", bold=True), "=": Style(color="#008700", bold=True), "<": Style(color="#d78700", bold=True), + "!": warning_style, } for spec in specs: @@ -53,7 +56,13 @@ def format_bump_table(specs, policy_versions, releases, warnings, ignored_violat policy_date = policy_release.timestamp required_version = spec.version - required_date = lookup_spec_release(spec, releases).timestamp + if required_version is None: + warnings[spec.name].append( + "Unpinned dependency. Consider pinning or ignoring this dependency." + ) + required_date = None + else: + required_date = lookup_spec_release(spec, releases).timestamp status = version_comparison_symbol(required_version, policy_version) if status == ">" and spec.name in ignored_violations: @@ -63,8 +72,8 @@ def format_bump_table(specs, policy_versions, releases, warnings, ignored_violat table.add_row( spec.name, - str(required_version), - f"{required_date:%Y-%m-%d}", + str(required_version) if required_version is not None else "", + f"{required_date:%Y-%m-%d}" if required_date is not None else "", str(policy_version), f"{policy_date:%Y-%m-%d}", status, diff --git a/minimum_versions/main.py b/minimum_versions/main.py index 0f3ffc0..4857c2a 100644 --- a/minimum_versions/main.py +++ b/minimum_versions/main.py @@ -1,17 +1,19 @@ import datetime +import os.path import pathlib import sys +from typing import Any import rich_click as click from rich.console import Console from rich.panel import Panel from rich.table import Table -from tlz.itertoolz import concat +from tlz.itertoolz import concat, unique +from minimum_versions.environments import compare_versions, parse_environment from minimum_versions.formatting import format_bump_table from minimum_versions.policy import find_policy_versions, parse_policy from minimum_versions.release import fetch_releases -from minimum_versions.spec import compare_versions, parse_environment click.rich_click.SHOW_ARGUMENTS = True @@ -23,26 +25,39 @@ def parse_date(string): return datetime.datetime.strptime(string, "%Y-%m-%d").date() +class _Path(click.Path): + def convert( + self, value: Any, param: click.Parameter | None, ctx: click.Context | None + ) -> Any: + if not value: + return None + + return super().convert(value, param, ctx) + + @click.group() def main(): pass @main.command() -@click.argument( - "environment_paths", - type=click.Path(exists=True, readable=True, path_type=pathlib.Path), - nargs=-1, +@click.argument("environment_paths", type=str, nargs=-1) +@click.option( + "--manifest-path", + "manifest_path", + type=_Path(exists=True, path_type=pathlib.Path), + default=None, ) @click.option("--today", type=parse_date, default=None) @click.option("--policy", "policy_file", type=click.File(mode="r"), required=True) -def validate(today, policy_file, environment_paths): +def validate(today, policy_file, manifest_path, environment_paths): console = Console() policy = parse_policy(policy_file) parsed_environments = { - path.stem: parse_environment(path.read_text()) for path in environment_paths + path.rsplit(os.path.sep, maxsplit=1)[-1]: parse_environment(path, manifest_path) + for path in environment_paths } warnings = { @@ -54,7 +69,11 @@ def validate(today, policy_file, environment_paths): } all_packages = list( - dict.fromkeys(spec.name for spec in concat(environments.values())) + unique( + spec.name + for spec in concat(environments.values()) + if spec.name not in policy.exclude + ) ) package_releases = fetch_releases(policy.channels, policy.platforms, all_packages) diff --git a/minimum_versions/policy.py b/minimum_versions/policy.py index 3d25bf2..a167c04 100644 --- a/minimum_versions/policy.py +++ b/minimum_versions/policy.py @@ -82,6 +82,8 @@ def minimum_version(self, today, package_name, releases): suitable_releases = [ release for release in releases if is_suitable_release(release) ] + if not suitable_releases: + raise ValueError(f"Cannot find valid releases for {package_name}") policy_months = self.package_months.get(package_name, self.default_months) @@ -90,6 +92,7 @@ def minimum_version(self, today, package_name, releases): index = bisect.bisect_left( suitable_releases, cutoff_date, key=lambda x: x.timestamp.date() ) + return suitable_releases[index - 1 if index > 0 else 0] diff --git a/minimum_versions/spec.py b/minimum_versions/spec.py deleted file mode 100644 index a7c72d5..0000000 --- a/minimum_versions/spec.py +++ /dev/null @@ -1,63 +0,0 @@ -from dataclasses import dataclass - -import yaml -from rattler import Version - - -@dataclass -class Spec: - name: str - version: Version | None - - @classmethod - def parse(cls, spec_text): - warnings = [] - if ">" in spec_text or "<" in spec_text: - warnings.append( - f"package must be pinned with an exact version: {spec_text!r}. Using the version as an exact pin instead." - ) - - spec_text = spec_text.replace(">", "").replace("<", "") - - if "=" in spec_text: - name, version_text = spec_text.split("=", maxsplit=1) - version = Version(version_text) - segments = version.segments() - - if (len(segments) == 3 and segments[2] != [0]) or len(segments) > 3: - warnings.append( - f"package should be pinned to a minor version (got {version})" - ) - else: - name = spec_text - version = None - - return cls(name, version), (name, warnings) - - -def parse_environment(text): - env = yaml.safe_load(text) - - specs = [] - warnings = [] - for dep in env["dependencies"]: - spec, warnings_ = Spec.parse(dep) - - specs.append(spec) - warnings.append(warnings_) - - return specs, warnings - - -def compare_versions(environments, policy_versions, ignored_violations): - status = {} - for env, specs in environments.items(): - env_status = any( - ( - spec.name not in ignored_violations - and spec.version > policy_versions[spec.name].version - ) - for spec in specs - ) - status[env] = env_status - return status diff --git a/minimum_versions/tests/test_environments.py b/minimum_versions/tests/test_environments.py new file mode 100644 index 0000000..1592d31 --- /dev/null +++ b/minimum_versions/tests/test_environments.py @@ -0,0 +1,392 @@ +import io +import pathlib +import textwrap +from dataclasses import dataclass + +import pytest +from rattler import Version + +from minimum_versions import environments +from minimum_versions.environments.spec import Spec + + +@dataclass +class FakeRecord: + version: Version | None + + +@pytest.mark.parametrize("manifest_path", ("a/pixi.toml", "b/pyproject.toml", None)) +@pytest.mark.parametrize( + ["specifier", "key"], (("conda:ci/environment.yml", "conda"), ("pixi:env", "pixi")) +) +def test_parse_environment(specifier, manifest_path, key, monkeypatch): + results = {"conda": object(), "pixi": object()} + kinds = { + "conda": lambda s, m: results["conda"], + "pixi": lambda s, m: results["pixi"], + } + monkeypatch.setattr(environments, "kinds", kinds) + + actual = environments.parse_environment(specifier, manifest_path) + expected = results[key] + + assert actual is expected + + +@pytest.mark.parametrize( + ["envs", "ignored_violations", "expected"], + ( + pytest.param( + { + "env1": [ + Spec("a", Version("1.2")), + Spec("c", Version("2024.8")), + Spec("d", Version("0.6")), + ] + }, + ["d"], + {"env1": False}, + id="single-violation-ignored", + ), + pytest.param( + { + "env1": [Spec("b", Version("3.2")), Spec("c", Version("2025.2"))], + "env2": [Spec("b", Version("3.1"))], + }, + [], + {"env1": True, "env2": False}, + id="multiple-split-not ignored", + ), + pytest.param( + {"env1": [Spec("d", None)]}, + [], + {"env1": True}, + id="single-none-not ignored", + ), + pytest.param( + {"env1": [Spec("d", None)]}, + ["d"], + {"env1": False}, + id="single-none-ignored", + ), + ), +) +def test_compare_versions(envs, ignored_violations, expected): + policy_versions = { + "a": FakeRecord(version=Version("1.2")), + "b": FakeRecord(version=Version("3.1")), + "c": FakeRecord(version=Version("2025.1")), + "d": FakeRecord(version=Version("0.5")), + } + + actual = environments.spec.compare_versions( + envs, policy_versions, ignored_violations + ) + assert actual == expected + + +class TestCondaEnvironment: + @pytest.mark.parametrize( + ["spec_text", "expected_spec", "expected_warnings"], + ( + pytest.param( + "a=3.2", Spec("a", Version("3.2")), [], id="exact-no_warnings" + ), + pytest.param( + "b>=1.1", + Spec("b", Version("1.1")), + [ + "package must be pinned with an exact version: 'b>=1.1'." + " Using the version as an exact pin instead." + ], + id="lower_bound", + ), + pytest.param( + "b<=4.1", + Spec("b", Version("4.1")), + [ + "package must be pinned with an exact version: 'b<=4.1'." + " Using the version as an exact pin instead." + ], + id="upper_equal_bound", + ), + pytest.param( + "b<4.1", + Spec("b", Version("4.1")), + [ + "package must be pinned with an exact version: 'b<=4.1'." + " Using the version as an exact pin instead." + ], + marks=pytest.mark.xfail( + reason="exclusive upper bounds are not supported" + ), + id="upper_bound", + ), + pytest.param( + "b>4.1", + Spec("b", Version("4.1")), + [ + "package must be pinned with an exact version: 'b>4.1'." + " Using the version as an exact pin instead." + ], + marks=pytest.mark.xfail( + reason="exclusive lower bounds are not supported" + ), + id="lower_bound", + ), + pytest.param( + "c=1.6.2", + Spec("c", Version("1.6.2")), + ["package should be pinned to a minor version (got 1.6.2)"], + ), + ), + ) + def test_parse_spec(self, spec_text, expected_spec, expected_warnings): + actual_spec, (actual_name, actual_warnings) = environments.conda.parse_spec( + spec_text + ) + + assert actual_spec == expected_spec + assert actual_name == expected_spec.name + assert actual_warnings == expected_warnings + + def test_parse_environment(self, monkeypatch): + data = textwrap.dedent( + """\ + channels: + - conda-forge + dependencies: + - a=1.1 + - b>=3.2 + - c=1.6.5 + """.rstrip() + ) + monkeypatch.setattr(pathlib.Path, "read_text", lambda _: data) + + expected_specs = [ + Spec("a", Version("1.1")), + Spec("b", Version("3.2")), + Spec("c", Version("1.6.5")), + ] + expected_warnings = [ + ("a", []), + ( + "b", + [ + "package must be pinned with an exact version: 'b>=3.2'." + " Using the version as an exact pin instead." + ], + ), + ("c", ["package should be pinned to a minor version (got 1.6.5)"]), + ] + + actual_specs, actual_warnings = environments.conda.parse_conda_environment( + "env1.yaml", None + ) + + assert actual_specs == expected_specs + assert actual_warnings == expected_warnings + + +class TestPixiEnvironment: + @pytest.mark.parametrize( + ["name", "version_text", "expected_spec", "expected_warnings"], + ( + pytest.param( + "a", "1.2.*", Spec("a", Version("1.2")), [], id="star_pin–no_warnings" + ), + pytest.param( + "b", + ">=3.1", + Spec("b", Version("3.1")), + [ + "package must be pinned with an exact version: '>=3.1'." + " Using the version as an exact pin instead." + ], + id="lower_pin", + ), + pytest.param( + "c", + ">=1.6.0,<1.7.0", + Spec("c", Version("1.6")), + [ + "lower pin '1.6.0' and upper pin '1.7.0' found." + " Using the lower pin for now, please convert to" + " the standard x.y.* syntax." + ], + id="tight_pin", + ), + pytest.param( + "d", + "1.9.1", + Spec("d", Version("1.9.1")), + ["package should be pinned to a minor version (got 1.9.1)"], + id="patch_pin", + ), + pytest.param("e", "*", Spec("e", None), [], id="unpinned"), + pytest.param("f", {"path": "."}, Spec("f", None), [], id="source_package"), + ), + ) + def test_parse_spec(self, name, version_text, expected_spec, expected_warnings): + actual_spec, (actual_name, actual_warnings) = environments.pixi.parse_spec( + name, version_text + ) + + assert actual_spec == expected_spec + assert actual_name == name + assert actual_warnings == expected_warnings + + @pytest.mark.parametrize("version_text", ("~1.3", "^2.1", "<1.1", "<=2025.01")) + def test_parse_spec_error(self, version_text): + with pytest.raises(ValueError, match="Unsupported version spec: .*"): + environments.pixi.parse_spec("package", version_text) + + @pytest.mark.parametrize( + ["data", "path", "expected_specs", "expected_warnings"], + ( + pytest.param( + textwrap.dedent( + """\ + [dependencies] + a = "1.0.*" + b = "2.2.*" + + [feature.feature1.dependencies] + c = "3.1.*" + + [environments] + env1 = { features = ["feature1"] } + """.rstrip() + ), + "pixi.toml", + [ + Spec("a", Version("1.0")), + Spec("b", Version("2.2")), + Spec("c", Version("3.1")), + ], + [("a", []), ("b", []), ("c", [])], + id="default-feature", + ), + pytest.param( + textwrap.dedent( + """\ + [dependencies] + a = "1.0.*" + b = "2.2.*" + + [feature.feature1.dependencies] + c = "3.1.*" + + [environments] + env1 = { features = ["feature1"], no-default-feature = true } + """.rstrip() + ), + "pixi.toml", + [Spec("c", Version("3.1"))], + [("c", [])], + id="no-default-feature", + ), + pytest.param( + textwrap.dedent( + """\ + [dependencies] + a = "1.0.*" + + [environments] + env1 = { features = [] } + """.rstrip() + ), + "pixi.toml", + [Spec("a", Version("1.0"))], + [("a", [])], + id="missing-features", + ), + pytest.param( + textwrap.dedent( + """\ + [dependencies] + a = "1.0.*" + + [pypi-dependencies] + b = "3.2.*" + + [environments] + env1 = { features = [] } + """.rstrip() + ), + "pixi.toml", + [Spec("a", Version("1.0"))], + [("feature:default", ["Ignored PyPI dependencies."]), ("a", [])], + id="pypi_dependencies-default", + ), + pytest.param( + textwrap.dedent( + """\ + [dependencies] + a = "1.0.*" + + [feature.feat1.pypi-dependencies] + b = "3.2.*" + + [environments] + env1 = { features = ["feat1"] } + """.rstrip() + ), + "pixi.toml", + [Spec("a", Version("1.0"))], + [("feature:feat1", ["Ignored PyPI dependencies."]), ("a", [])], + id="pypi_dependencies-feat1", + ), + pytest.param( + textwrap.dedent( + """\ + [tool.pixi.feature.feature1.dependencies] + c = "3.1.*" + + [tool.pixi.environments] + env1 = { features = ["feature1"], no-default-feature = true } + """.rstrip() + ), + "pyproject.toml", + [Spec("c", Version("3.1"))], + [("c", [])], + id="pyproject", + ), + pytest.param( + textwrap.dedent( + """\ + [package] + name = "a" + + [dependencies] + a = { path = "." } + + [feature.feature1.dependencies] + c = "3.1.*" + + [environments] + env1 = { features = ["feature1"] } + """.rstrip() + ), + "pixi.toml", + [Spec("c", Version("3.1"))], + [("c", [])], + id="local_package", + ), + ), + ) + def test_parse_pixi_environment( + self, monkeypatch, path, data, expected_specs, expected_warnings + ): + monkeypatch.setattr( + pathlib.Path, "open", lambda _, mode: io.BytesIO(data.encode()) + ) + + name = "env1" + manifest_path = pathlib.Path(path) + + actual_specs, actual_warnings = environments.pixi.parse_pixi_environment( + name, manifest_path + ) + assert actual_specs == expected_specs + assert actual_warnings == expected_warnings diff --git a/minimum_versions/tests/test_release.py b/minimum_versions/tests/test_release.py new file mode 100644 index 0000000..875a59e --- /dev/null +++ b/minimum_versions/tests/test_release.py @@ -0,0 +1,112 @@ +import datetime as dt +from dataclasses import dataclass + +import pytest +from rattler import PackageName, Version + +from minimum_versions import release + + +@dataclass +class FakePackageRecord: + name: PackageName + version: Version + build_number: int + timestamp: dt.datetime + + +@pytest.fixture +def timestamps(): + yield [ + dt.datetime(2025, 12, 2, 20, 24, 40), + dt.datetime(2025, 12, 2, 20, 25, 40), + dt.datetime(2025, 12, 2, 20, 20, 40), + ] + + +@pytest.fixture +def records(timestamps): + yield [ + FakePackageRecord( + name=PackageName("test1"), + version=Version("1.0.0"), + build_number=1, + timestamp=timestamps[0], + ), + FakePackageRecord( + name=PackageName("test1"), + version=Version("1.0.1"), + build_number=0, + timestamp=None, + ), + FakePackageRecord( + name=PackageName("test2"), + version=Version("1.0.0"), + build_number=0, + timestamp=timestamps[2], + ), + ] + + +@pytest.fixture +def releases(timestamps): + yield { + "test1": [ + release.Release( + version=Version("1.0.0"), build_number=1, timestamp=timestamps[0] + ), + release.Release(version=Version("1.0.1"), build_number=0, timestamp=None), + ], + "test2": [ + release.Release( + version=Version("1.0.0"), build_number=0, timestamp=timestamps[2] + ) + ], + } + + +def test_release_from_repodata_record(): + repo_data = FakePackageRecord( + name=PackageName("test"), + version=Version("1.0.1"), + build_number=0, + timestamp=dt.datetime(2025, 12, 2, 20, 24, 40), + ) + + actual = release.Release.from_repodata_record(repo_data) + + assert actual.version == repo_data.version + assert actual.build_number == repo_data.build_number + assert actual.timestamp == repo_data.timestamp + + +def test_group_packages(records, releases): + actual = release.group_packages(records) + expected = releases + + assert actual == expected + + +@pytest.mark.parametrize( + ["predicate", "expected"], + ( + ( + lambda r: r.timestamp is None, + {"test1": [release.Release(Version("1.0.1"), 0, None)], "test2": []}, + ), + ( + lambda r: r.build_number == 1, + { + "test1": [ + release.Release( + Version("1.0.0"), 1, dt.datetime(2025, 12, 2, 20, 24, 40) + ) + ], + "test2": [], + }, + ), + ), +) +def test_filter_releases(releases, predicate, expected): + actual = release.filter_releases(predicate, releases) + assert actual == expected diff --git a/minimum_versions/tests/test_spec.py b/minimum_versions/tests/test_spec.py index 64b4252..df1c64d 100644 --- a/minimum_versions/tests/test_spec.py +++ b/minimum_versions/tests/test_spec.py @@ -1,7 +1,7 @@ import pytest from rattler import Version -from minimum_versions.spec import Spec +from minimum_versions.environments import Spec, conda @pytest.mark.parametrize( @@ -17,8 +17,8 @@ ), ), ) -def test_spec_parse(text, expected_spec, expected_name, expected_warnings): - actual_spec, (actual_name, actual_warnings) = Spec.parse(text) +def test_parse_conda_spec(text, expected_spec, expected_name, expected_warnings): + actual_spec, (actual_name, actual_warnings) = conda.parse_spec(text) assert actual_spec == expected_spec assert actual_name == expected_name diff --git a/pyproject.toml b/pyproject.toml index bacf560..0e241db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,40 @@ +[project] +name = "minimum-dependency-versions" +authors = [ + { name = "Justus Magin" }, +] +license = "Apache-2.0" +description = "Validate minimum dependency environments according to xarray's policy scheme" +requires-python = ">=3.11" +dependencies = [ + "rich", + "rich-click", + "pyyaml", + "cytoolz", + "py-rattler", + "python-dateutil", + "jsonschema", +] +dynamic = ["version"] + +[project.urls] +Repository = "https://github.com/xarray-contrib/minimum-dependency-versions" + +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[tool.hatch] +version.source = "vcs" + +[tool.hatch.metadata.hooks.vcs] + +[tool.hatch.build.targets.sdist] +only-include = ["minimum_versions"] + +[tool.hatch.build.targets.wheel] +only-include = ["minimum_versions"] + [tool.ruff] target-version = "py39" builtins = ["ellipsis"] @@ -35,3 +72,11 @@ fixable = ["I", "TID252"] [tool.ruff.lint.flake8-tidy-imports] # Disallow all relative imports. ban-relative-imports = "all" + +[tool.coverage.run] +source = ["minimum_versions"] +branch = true + +[tool.coverage.report] +show_missing = true +exclude_lines = ["pragma: no cover", "if TYPE_CHECKING"]