blob: 8a81dd098c6f128f89d43e3bb6124d18cbcb15f2 [file] [log] [blame]
#!/usr/bin/env python
#
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# /// script
# requires-python = ">=3.10,<3.11"
# dependencies = [
# "rich>=13.6.0",
# ]
# ///
"""
Check that all airflowctl CLI commands have integration test coverage by comparing commands from operations.py against test_commands in conftest.py.
"""
from __future__ import annotations
import ast
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.resolve()))
from common_prek_utils import AIRFLOW_ROOT_PATH, console
OPERATIONS_FILE = AIRFLOW_ROOT_PATH / "airflow-ctl" / "src" / "airflowctl" / "api" / "operations.py"
CTL_TESTS_FILE = (
AIRFLOW_ROOT_PATH / "airflow-ctl-tests" / "tests" / "airflowctl_tests" / "test_airflowctl_commands.py"
)
# Operations excluded from CLI (see cli_config.py)
EXCLUDED_OPERATION_CLASSES = {"BaseOperations", "LoginOperations", "VersionOperations"}
EXCLUDED_METHODS = {
"__init__",
"__init_subclass__",
"error",
"_check_flag_and_exit_if_server_response_error",
"bulk",
"export",
}
EXCLUDED_COMMANDS = {
"assets delete-dag-queued-events",
"assets delete-queued-event",
"assets delete-queued-events",
"assets get-by-alias",
"assets get-dag-queued-event",
"assets get-dag-queued-events",
"assets get-queued-events",
"assets list-by-alias",
"assets materialize",
"backfill cancel",
"backfill create",
"backfill create-dry-run",
"backfill get",
"backfill pause",
"backfill unpause",
"connections create-defaults",
"connections test",
"dags delete",
"dags get-import-error",
"dags get-tags",
}
def parse_operations() -> dict[str, list[str]]:
commands: dict[str, list[str]] = {}
with open(OPERATIONS_FILE) as f:
tree = ast.parse(f.read(), filename=str(OPERATIONS_FILE))
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef) and node.name.endswith("Operations"):
if node.name in EXCLUDED_OPERATION_CLASSES:
continue
group_name = node.name.replace("Operations", "").lower()
commands[group_name] = []
for child in node.body:
if isinstance(child, ast.FunctionDef):
method_name = child.name
if method_name in EXCLUDED_METHODS or method_name.startswith("_"):
continue
subcommand = method_name.replace("_", "-")
commands[group_name].append(subcommand)
return commands
def parse_tested_commands() -> set[str]:
tested: set[str] = set()
with open(CTL_TESTS_FILE) as f:
content = f.read()
# Match command patterns like "assets list", "dags list-import-errors", etc.
# Also handles f-strings like f"dagrun get..." or f'dagrun get...'
pattern = r'f?["\']([a-z]+(?:-[a-z]+)*\s+[a-z]+(?:-[a-z]+)*)'
for match in re.findall(pattern, content):
parts = match.split()
if len(parts) >= 2:
tested.add(f"{parts[0]} {parts[1]}")
return tested
def main():
available = parse_operations()
tested = parse_tested_commands()
missing = []
for group, subcommands in sorted(available.items()):
for subcommand in sorted(subcommands):
cmd = f"{group} {subcommand}"
if cmd not in tested and cmd not in EXCLUDED_COMMANDS:
missing.append(cmd)
if missing:
console.print("[red]ERROR: Commands not covered by integration tests:[/]")
for cmd in missing:
console.print(f" [red]- {cmd}[/]")
console.print()
console.print("[yellow]Fix by either:[/]")
console.print(f"1. Add test to {CTL_TESTS_FILE}")
console.print(f"2. Add to EXCLUDED_COMMANDS in {__file__}")
sys.exit(1)
total = sum(len(cmds) for cmds in available.values())
console.print(
f"[green]All {total} CLI commands covered ({len(tested)} tested, {len(EXCLUDED_COMMANDS)} excluded)[/]"
)
sys.exit(0)
if __name__ == "__main__":
main()