This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 7c2e201c1e8 Add prek hook to catch operators missing from common-ai
docs index (#71783)
7c2e201c1e8 is described below
commit 7c2e201c1e8b4d4c4990d759a08b2dd4008394a8
Author: Jyun-An Chen <[email protected]>
AuthorDate: Tue Aug 25 22:05:49 2026 +0800
Add prek hook to catch operators missing from common-ai docs index (#71783)
* Add test to catch operators missing from common-ai docs index
The "Choosing the right operator" table in
providers/common/ai/docs/operators/index.rst is hand-maintained and has
already gone stale more than once (LLMSchemaCompareOperator was missing
until #71738). Nothing else catches this: the Sphinx doc build only fails
on broken `:class:` refs, not on a class that's simply absent from the
table. This adds a regression test so the next missed operator fails CI
instead of relying on manual review.
* Replace common-ai docs index test with a prek hook
A unit test only guards this when the provider's test suite happens to be
selected in CI, but the pairing it protects — the operators package and the
hand-maintained docs index — is just as likely to drift from a docs-only
edit.
A prek hook keyed on both sides catches the gap at commit time instead.
---
providers/.pre-commit-config.yaml | 7 ++
scripts/ci/prek/check_common_ai_operators_index.py | 114 +++++++++++++++++++++
2 files changed, 121 insertions(+)
diff --git a/providers/.pre-commit-config.yaml
b/providers/.pre-commit-config.yaml
index 27d886f162a..4c624358aae 100644
--- a/providers/.pre-commit-config.yaml
+++ b/providers/.pre-commit-config.yaml
@@ -221,6 +221,13 @@ repos:
files:
^.*/provider\.yaml$|^.*/docs/.*/connections.*\.rst$|^.*/docs/connections\.rst$
require_serial: true
pass_filenames: false
+ - id: check-common-ai-operators-index
+ name: Check common.ai operators are listed in the docs index
+ entry: ../scripts/ci/prek/check_common_ai_operators_index.py
+ language: python
+ files:
^common/ai/(src/airflow/providers/common/ai/operators/.*\.py|docs/operators/index\.rst)$
+ require_serial: true
+ pass_filenames: false
- id: mypy-providers
stages: ['pre-push']
name: Run mypy for providers
diff --git a/scripts/ci/prek/check_common_ai_operators_index.py
b/scripts/ci/prek/check_common_ai_operators_index.py
new file mode 100755
index 00000000000..01e5f1b04a4
--- /dev/null
+++ b/scripts/ci/prek/check_common_ai_operators_index.py
@@ -0,0 +1,114 @@
+#!/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",
+# ]
+# ///
+"""
+Ensure every common.ai operator is listed in ``docs/operators/index.rst``.
+
+The "Choosing the right operator" table in that file is hand-maintained and
has already
+silently gone stale once — ``LLMSchemaCompareOperator`` was missing from it
until #71738.
+Nothing else in CI catches this class of gap.
+
+Only classes *defined in* the operators modules are considered, so imported
helpers
+(mixins, ``BaseOperator`` itself, pydantic models, operator links) are never
flagged.
+"""
+
+from __future__ import annotations
+
+import ast
+import re
+import sys
+from pathlib import Path
+
+from rich.console import Console
+
+sys.path.insert(0, str(Path(__file__).parent.resolve()))
+
+from common_prek_utils import AIRFLOW_PROVIDERS_ROOT_PATH
+
+console = Console(color_system="standard", width=200)
+
+PROVIDER_PATH = AIRFLOW_PROVIDERS_ROOT_PATH / "common" / "ai"
+OPERATORS_PATH = PROVIDER_PATH / "src" / "airflow" / "providers" / "common" /
"ai" / "operators"
+INDEX_RST = PROVIDER_PATH / "docs" / "operators" / "index.rst"
+
+# Bases that mark a class as an operator. Local operators subclass each other
(e.g.
+# LLMFileAnalysisOperator -> LLMOperator), so any name ending in "Operator"
counts as a base too.
+_OPERATOR_BASE_SUFFIX = "Operator"
+# Classes that are operator-like by name but are not operators users would
document.
+_EXCLUDED = {"BaseOperator"}
+
+
+def extract_base_names(class_def: ast.ClassDef) -> list[str]:
+ names = []
+ for base in class_def.bases:
+ if isinstance(base, ast.Name):
+ names.append(base.id)
+ elif isinstance(base, ast.Attribute):
+ names.append(base.attr)
+ return names
+
+
+def find_operator_classes() -> list[str]:
+ """Return operator class names defined directly in the operators
package."""
+ operators: list[str] = []
+ for module_path in sorted(OPERATORS_PATH.glob("*.py")):
+ if module_path.name == "__init__.py":
+ continue
+ tree = ast.parse(module_path.read_text(), filename=str(module_path))
+ for node in tree.body:
+ if not isinstance(node, ast.ClassDef) or node.name in _EXCLUDED:
+ continue
+ if any(base.endswith(_OPERATOR_BASE_SUFFIX) for base in
extract_base_names(node)):
+ operators.append(node.name)
+ return operators
+
+
+def main() -> int:
+ if not INDEX_RST.is_file():
+ console.print(f"[red]Cannot find {INDEX_RST}[/]")
+ return 1
+
+ index_contents = INDEX_RST.read_text()
+ # Whole-word match: a bare substring check would let LLMOperator pass on a
page that only
+ # mentions LLMBranchOperator, and would not notice a name gaining a typo'd
suffix.
+ undocumented_operators = sorted(
+ name for name in find_operator_classes() if not
re.search(rf"\b{re.escape(name)}\b", index_contents)
+ )
+
+ if undocumented_operators:
+ console.print()
+ for name in undocumented_operators:
+ console.print(f" [red]✗[/] {name} is not mentioned in
{INDEX_RST}")
+ console.print()
+ console.print(
+ "[red]Add the operator(s) above to the 'Choosing the right
operator' table "
+ f"in
{INDEX_RST.relative_to(AIRFLOW_PROVIDERS_ROOT_PATH.parent)}.[/]"
+ )
+ return 1
+
+ console.print("[green]All common.ai operators are listed in the docs
index.[/]")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())