blob: 2c299f57fd4a984a85cfa029875b49d110c1e72f [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.11"
# dependencies = [
# "rich>=13.0.0",
# ]
# ///
"""Check that ``mapped_column(..., nullable=True)`` attributes are annotated ``Mapped[X | None]``.
SQLAlchemy 2 derives a column's nullability from its ``Mapped[...]`` annotation only when
``nullable=`` is *not* passed explicitly. Once ``nullable=True`` is spelled out the DDL is
right, but nothing cross-checks the annotation any more: ``Mapped[datetime]`` on such a column
tells mypy the attribute can never be ``None``, so callers skip the ``None`` guard and fail at
runtime with ``AttributeError`` the first time they touch a NULL row.
The check is deliberately one-directional. An Optional annotation on a NOT NULL column is
harmless and is not reported.
Modes
-----
Default (files passed by prek):
Check only the supplied files.
``--all-files``:
Walk the directories that define ORM models (``models`` and ``jobs`` in airflow-core,
and the ``models`` packages of the edge3 and FAB providers) and check every ``.py`` file.
"""
from __future__ import annotations
import argparse
import ast
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from pathlib import Path
from common_prek_utils import AIRFLOW_ROOT_PATH
from rich.console import Console
from rich.markup import escape
console = Console(color_system="standard", width=200)
REPO_ROOT = AIRFLOW_ROOT_PATH
# Keep in sync with the hook's ``files`` pattern in .pre-commit-config.yaml.
SCAN_ROOTS: tuple[str, ...] = (
"airflow-core/src/airflow/models",
"airflow-core/src/airflow/jobs",
"providers/edge3/src/airflow/providers/edge3/models",
"providers/fab/src/airflow/providers/fab/auth_manager/models",
)
_NONE_ADMITTING_NAMES = frozenset({"Any", "Optional"})
@dataclass(frozen=True)
class NullableAnnotationMismatch:
path: Path
lineno: int
attribute: str
annotation: str
def _extract_trailing_name(node: ast.expr) -> str | None:
"""Return the last identifier of a ``Name``/``Attribute`` chain (``orm.Mapped`` -> ``Mapped``)."""
if isinstance(node, ast.Name):
return node.id
if isinstance(node, ast.Attribute):
return node.attr
return None
def _resolve_string_annotation(node: ast.expr) -> ast.expr:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
try:
return ast.parse(node.value, mode="eval").body
except SyntaxError:
return node
return node
def admits_none(annotation: ast.expr) -> bool:
"""Return True if the annotation can hold ``None``: ``X | None``, ``Optional[X]``, ``Union[X, None]``, ``Any``."""
annotation = _resolve_string_annotation(annotation)
if isinstance(annotation, ast.Constant):
return annotation.value is None
if isinstance(annotation, ast.BinOp) and isinstance(annotation.op, ast.BitOr):
return admits_none(annotation.left) or admits_none(annotation.right)
if _extract_trailing_name(annotation) in _NONE_ADMITTING_NAMES:
return True
if isinstance(annotation, ast.Subscript):
outer = _extract_trailing_name(annotation.value)
if outer == "Optional":
return True
if outer == "Union":
members = annotation.slice.elts if isinstance(annotation.slice, ast.Tuple) else [annotation.slice]
return any(admits_none(member) for member in members)
return False
def _explicit_nullable(call: ast.Call) -> bool | None:
"""Return the literal ``nullable=`` value, or ``None`` when absent or not a plain boolean."""
for keyword in call.keywords:
if keyword.arg == "nullable":
value = keyword.value
if isinstance(value, ast.Constant) and isinstance(value.value, bool):
return value.value
return None
return None
def iter_mismatches(path: Path) -> Iterator[NullableAnnotationMismatch]:
"""Yield every ``Mapped[...] = mapped_column(..., nullable=True)`` whose annotation cannot be ``None``."""
try:
source = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return
if "mapped_column" not in source:
return
try:
tree = ast.parse(source, filename=str(path))
except SyntaxError:
return
for node in ast.walk(tree):
if not isinstance(node, ast.AnnAssign) or node.value is None:
continue
annotation = _resolve_string_annotation(node.annotation)
if not (
isinstance(annotation, ast.Subscript) and _extract_trailing_name(annotation.value) == "Mapped"
):
continue
call = node.value
if not (isinstance(call, ast.Call) and _extract_trailing_name(call.func) == "mapped_column"):
continue
if _explicit_nullable(call) is not True or admits_none(annotation.slice):
continue
yield NullableAnnotationMismatch(
path=path,
lineno=node.lineno,
attribute=ast.unparse(node.target),
annotation=ast.unparse(annotation),
)
def iter_python_files(roots: Iterable[Path]) -> Iterator[Path]:
for root in roots:
for path in sorted(root.rglob("*.py")):
relative_parts = path.relative_to(root).parts
if any(part.startswith(".") or part == "node_modules" for part in relative_parts):
continue
yield path
def _format_display_path(path: Path) -> str:
try:
return str(path.resolve().relative_to(REPO_ROOT))
except ValueError:
return str(path)
def check_files(files: Iterable[Path]) -> int:
mismatches = [mismatch for path in files for mismatch in iter_mismatches(path)]
if not mismatches:
return 0
console.print(
f"[red]Found {len(mismatches)} nullable mapped_column attribute(s) whose "
"Mapped annotation does not admit None:[/]\n",
highlight=False,
)
for mismatch in mismatches:
console.print(
f" {_format_display_path(mismatch.path)}:{mismatch.lineno}: "
f"[bold]{escape(mismatch.attribute)}[/]: {escape(mismatch.annotation)}",
highlight=False,
soft_wrap=True,
)
console.print(
"\nA column declared with [cyan]nullable=True[/] can hold NULL, so its annotation must say so: "
f"annotate it as [cyan]{escape('Mapped[X | None]')}[/], or drop [cyan]nullable=True[/] if the column "
"is meant to be NOT NULL. Otherwise mypy believes the attribute is never None and lets callers "
"skip the guard that the NULL rows require.",
highlight=False,
)
return 1
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Check that nullable mapped_column attributes are annotated Mapped[X | None].",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument("files", nargs="*", metavar="FILE", help="Files to check (provided by prek)")
parser.add_argument(
"--all-files",
action="store_true",
help="Check every Python file in the ORM model directories",
)
args = parser.parse_args(argv)
if args.all_files:
return check_files(iter_python_files(REPO_ROOT / root for root in SCAN_ROOTS))
if not args.files:
console.print("[yellow]No files provided. Pass filenames or use --all-files.[/yellow]")
return 0
return check_files(Path(file) for file in args.files)
if __name__ == "__main__":
raise SystemExit(main())