|
| 1 | +#!/usr/bin/env python |
| 2 | +# |
| 3 | +# Licensed to the Apache Software Foundation (ASF) under one |
| 4 | +# or more contributor license agreements. See the NOTICE file |
| 5 | +# distributed with this work for additional information |
| 6 | +# regarding copyright ownership. The ASF licenses this file |
| 7 | +# to you under the Apache License, Version 2.0 (the |
| 8 | +# "License"); you may not use this file except in compliance |
| 9 | +# with the License. You may obtain a copy of the License at |
| 10 | +# |
| 11 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 12 | +# |
| 13 | +# Unless required by applicable law or agreed to in writing, |
| 14 | +# software distributed under the License is distributed on an |
| 15 | +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 16 | +# KIND, either express or implied. See the License for the |
| 17 | +# specific language governing permissions and limitations |
| 18 | +# under the License. |
| 19 | +# /// script |
| 20 | +# requires-python = ">=3.10,<3.11" |
| 21 | +# dependencies = [ |
| 22 | +# "rich>=13.6.0", |
| 23 | +# ] |
| 24 | +# /// |
| 25 | +""" |
| 26 | +Check that all airflowctl CLI commands have integration test coverage by comparing commands from operations.py against test_commands in conftest.py. |
| 27 | +""" |
| 28 | + |
| 29 | +from __future__ import annotations |
| 30 | + |
| 31 | +import ast |
| 32 | +import re |
| 33 | +import sys |
| 34 | +from pathlib import Path |
| 35 | + |
| 36 | +sys.path.insert(0, str(Path(__file__).parent.resolve())) |
| 37 | +from common_prek_utils import AIRFLOW_ROOT_PATH, console |
| 38 | + |
| 39 | +OPERATIONS_FILE = AIRFLOW_ROOT_PATH / "airflow-ctl" / "src" / "airflowctl" / "api" / "operations.py" |
| 40 | +CONFTEST_FILE = AIRFLOW_ROOT_PATH / "airflow-ctl-tests" / "tests" / "airflowctl_tests" / "conftest.py" |
| 41 | + |
| 42 | +# Operations excluded from CLI (see cli_config.py) |
| 43 | +EXCLUDED_OPERATION_CLASSES = {"BaseOperations", "LoginOperations", "VersionOperations"} |
| 44 | +EXCLUDED_METHODS = { |
| 45 | + "__init__", |
| 46 | + "__init_subclass__", |
| 47 | + "error", |
| 48 | + "_check_flag_and_exit_if_server_response_error", |
| 49 | + "bulk", |
| 50 | +} |
| 51 | + |
| 52 | +EXCLUDED_COMMANDS = { |
| 53 | + "assets delete-dag-queued-events", |
| 54 | + "assets delete-queued-event", |
| 55 | + "assets delete-queued-events", |
| 56 | + "assets get-by-alias", |
| 57 | + "assets get-dag-queued-event", |
| 58 | + "assets get-dag-queued-events", |
| 59 | + "assets get-queued-events", |
| 60 | + "assets list-by-alias", |
| 61 | + "assets materialize", |
| 62 | + "backfill cancel", |
| 63 | + "backfill create", |
| 64 | + "backfill create-dry-run", |
| 65 | + "backfill get", |
| 66 | + "backfill pause", |
| 67 | + "backfill unpause", |
| 68 | + "connections create-defaults", |
| 69 | + "connections test", |
| 70 | + "dags delete", |
| 71 | + "dags get-import-error", |
| 72 | + "dags get-tags", |
| 73 | +} |
| 74 | + |
| 75 | + |
| 76 | +def parse_operations() -> dict[str, list[str]]: |
| 77 | + commands: dict[str, list[str]] = {} |
| 78 | + |
| 79 | + with open(OPERATIONS_FILE) as f: |
| 80 | + tree = ast.parse(f.read(), filename=str(OPERATIONS_FILE)) |
| 81 | + |
| 82 | + for node in ast.walk(tree): |
| 83 | + if isinstance(node, ast.ClassDef) and node.name.endswith("Operations"): |
| 84 | + if node.name in EXCLUDED_OPERATION_CLASSES: |
| 85 | + continue |
| 86 | + |
| 87 | + group_name = node.name.replace("Operations", "").lower() |
| 88 | + commands[group_name] = [] |
| 89 | + |
| 90 | + for child in node.body: |
| 91 | + if isinstance(child, ast.FunctionDef): |
| 92 | + method_name = child.name |
| 93 | + if method_name in EXCLUDED_METHODS or method_name.startswith("_"): |
| 94 | + continue |
| 95 | + subcommand = method_name.replace("_", "-") |
| 96 | + commands[group_name].append(subcommand) |
| 97 | + |
| 98 | + return commands |
| 99 | + |
| 100 | + |
| 101 | +def parse_tested_commands() -> set[str]: |
| 102 | + tested: set[str] = set() |
| 103 | + |
| 104 | + with open(CONFTEST_FILE) as f: |
| 105 | + content = f.read() |
| 106 | + |
| 107 | + # Match command patterns like "assets list", "dags list-import-errors", etc. |
| 108 | + # Also handles f-strings like f"dagrun get..." or f'dagrun get...' |
| 109 | + pattern = r'f?["\']([a-z]+(?:-[a-z]+)*\s+[a-z]+(?:-[a-z]+)*)' |
| 110 | + for match in re.findall(pattern, content): |
| 111 | + parts = match.split() |
| 112 | + if len(parts) >= 2: |
| 113 | + tested.add(f"{parts[0]} {parts[1]}") |
| 114 | + |
| 115 | + return tested |
| 116 | + |
| 117 | + |
| 118 | +def main(): |
| 119 | + available = parse_operations() |
| 120 | + tested = parse_tested_commands() |
| 121 | + |
| 122 | + missing = [] |
| 123 | + for group, subcommands in sorted(available.items()): |
| 124 | + for subcommand in sorted(subcommands): |
| 125 | + cmd = f"{group} {subcommand}" |
| 126 | + if cmd not in tested and cmd not in EXCLUDED_COMMANDS: |
| 127 | + missing.append(cmd) |
| 128 | + |
| 129 | + if missing: |
| 130 | + console.print("[red]ERROR: Commands not covered by integration tests:[/]") |
| 131 | + for cmd in missing: |
| 132 | + console.print(f" [red]- {cmd}[/]") |
| 133 | + console.print() |
| 134 | + console.print("[yellow]Fix by either:[/]") |
| 135 | + console.print("1. Add test to airflow-ctl-tests/tests/airflowctl_tests/conftest.py") |
| 136 | + console.print("2. Add to EXCLUDED_COMMANDS in scripts/ci/prek/check_airflowctl_command_coverage.py") |
| 137 | + sys.exit(1) |
| 138 | + |
| 139 | + total = sum(len(cmds) for cmds in available.values()) |
| 140 | + console.print( |
| 141 | + f"[green]All {total} CLI commands covered ({len(tested)} tested, {len(EXCLUDED_COMMANDS)} excluded)[/]" |
| 142 | + ) |
| 143 | + sys.exit(0) |
| 144 | + |
| 145 | + |
| 146 | +if __name__ == "__main__": |
| 147 | + main() |
0 commit comments