blob: 8d31740b757808257c8e27da1ec41662c5391c48 [file]
#!/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"
# dependencies = ["rich>=13.6.0"]
# ///
"""
Reject ``XComModel.<column>`` used directly in SQLAlchemy queries instead of through ``xcom_entity``.
``XComModel`` maps the unfiltered union of ``xcom_v1`` and ``xcom_v2``. A column of it used
in a query built by ``XComModel.get_many`` adds that union as a second ``FROM`` (a cartesian
product), and used on its own it relies on the planner pushing the predicate into the union.
Read the columns from the entity of the query instead: ``xcom_entity(query).<column>``.
Providers also run on Airflow releases where ``XComModel`` is a plain table, so there only
code inside an ``if AIRFLOW_V_3_N_PLUS:`` body (N >= 4) is checked.
Append ``# xcom-model-column: allow`` to a line that deliberately names the class attribute,
e.g. a declaration that is rebound to the query's entity before use.
"""
from __future__ import annotations
import ast
import re
import sys
from pathlib import Path
from typing import NamedTuple
from common_prek_utils import console
COLUMNS = frozenset(
{
"dag_id",
"dag_result",
"dag_run",
"dag_run_id",
"key",
"map_index",
"mapped_length",
"run_id",
"task",
"task_id",
"task_instance_id",
"timestamp",
"value",
}
)
ALLOW_MARKER = "xcom-model-column: allow"
DEFINING_MODULE = Path("airflow-core/src/airflow/models/xcom.py")
class ErrorLocation(NamedTuple):
line: int
column: int
attribute: str
NEW_XCOM_GUARD = re.compile(r"AIRFLOW_V_3_(\d+)_PLUS")
MINIMUM_GUARD_MINOR = 4
def is_new_xcom_guard(test: ast.expr) -> bool:
if not isinstance(test, ast.Name):
return False
match = NEW_XCOM_GUARD.fullmatch(test.id)
return match is not None and int(match.group(1)) >= MINIMUM_GUARD_MINOR
class ColumnVisitor(ast.NodeVisitor):
def __init__(self, lines: list[str], *, only_guarded: bool) -> None:
self.lines = lines
self.only_guarded = only_guarded
self.guard_depth = 0
self.errors: list[ErrorLocation] = []
def visit_If(self, node: ast.If) -> None:
self.visit(node.test)
guarded = is_new_xcom_guard(node.test)
self.guard_depth += guarded
for child in node.body:
self.visit(child)
self.guard_depth -= guarded
for child in node.orelse:
self.visit(child)
def visit_Attribute(self, node: ast.Attribute) -> None:
if (
isinstance(node.value, ast.Name)
and node.value.id == "XComModel"
and node.attr in COLUMNS
and (self.guard_depth or not self.only_guarded)
and ALLOW_MARKER not in self.lines[node.lineno - 1]
):
self.errors.append(ErrorLocation(node.lineno, node.col_offset + 1, node.attr))
self.generic_visit(node)
def check_source(source: str, filename: str = "<unknown>") -> list[ErrorLocation]:
visitor = ColumnVisitor(source.splitlines(), only_guarded="providers/" in Path(filename).as_posix())
visitor.visit(ast.parse(source, filename=filename))
return sorted(visitor.errors)
def main(filenames: list[str]) -> int:
failed = False
for filename in filenames:
if Path(filename).as_posix().endswith(DEFINING_MODULE.as_posix()):
continue
try:
errors = check_source(Path(filename).read_text(encoding="utf-8"), filename)
except SyntaxError as syntax_error:
console.print(f"[red]{filename}:{syntax_error.lineno}: could not parse: {syntax_error.msg}[/]")
failed = True
continue
for error in errors:
console.print(
f"[red]{filename}:{error.line}:{error.column}: `XComModel.{error.attribute}` is used directly "
"in a SQLAlchemy query; read it through `xcom_entity(query).<column>` instead.[/]"
)
failed = True
return int(failed)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))