This is an automated email from the ASF dual-hosted git repository.
Lee-W 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 987f9c08a89 Add None to Mapped annotations of three nullable ORM
columns (#72579)
987f9c08a89 is described below
commit 987f9c08a89b45204d7ea8444c576f1074c33e45
Author: PoAn Yang <[email protected]>
AuthorDate: Fri Sep 11 11:26:34 2026 +0900
Add None to Mapped annotations of three nullable ORM columns (#72579)
Signed-off-by: PoAn Yang <[email protected]>
---
.pre-commit-config.yaml | 11 +
airflow-core/src/airflow/models/backfill.py | 2 +-
airflow-core/src/airflow/models/callback.py | 2 +-
airflow-core/src/airflow/models/dagrun.py | 2 +-
.../check_mapped_column_nullable_annotations.py | 227 ++++++++++++++++++++
...est_check_mapped_column_nullable_annotations.py | 235 +++++++++++++++++++++
6 files changed, 476 insertions(+), 3 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4777b57ea24..e463adad7e7 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1358,6 +1358,17 @@ repos:
language: python
pass_filenames: false
files:
^airflow-core/src/airflow/utils/[^/]+$|^scripts/ci/prek/known_airflow_core_utils_modules\.txt$|^scripts/ci/prek/check_no_new_airflow_core_utils_modules\.py$
+ - id: check-mapped-column-nullable-annotations
+ name: Check that nullable mapped_column attributes are annotated
Mapped[X | None]
+ entry: ./scripts/ci/prek/check_mapped_column_nullable_annotations.py
+ language: python
+ pass_filenames: true
+ files: >
+ (?x)
+ ^airflow-core/src/airflow/(models|jobs)/.*\.py$|
+ ^providers/edge3/src/airflow/providers/edge3/models/.*\.py$|
+ ^providers/fab/src/airflow/providers/fab/auth_manager/models/.*\.py$|
+ ^scripts/ci/prek/check_mapped_column_nullable_annotations\.py$
- id: bandit
name: bandit
description: "Bandit is a tool for finding common security issues in
Python code"
diff --git a/airflow-core/src/airflow/models/backfill.py
b/airflow-core/src/airflow/models/backfill.py
index 13b9e6d004f..203be25188e 100644
--- a/airflow-core/src/airflow/models/backfill.py
+++ b/airflow-core/src/airflow/models/backfill.py
@@ -221,7 +221,7 @@ class BackfillDagRun(Base):
backfill_id: Mapped[int] = mapped_column(Integer, nullable=False)
dag_run_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
exception_reason: Mapped[str | None] = mapped_column(StringID(),
nullable=True)
- logical_date: Mapped[datetime] = mapped_column(UtcDateTime, nullable=True)
+ logical_date: Mapped[datetime | None] = mapped_column(UtcDateTime,
nullable=True)
partition_key: Mapped[str | None] = mapped_column(StringID(),
nullable=True)
sort_ordinal: Mapped[int] = mapped_column(Integer, nullable=False)
diff --git a/airflow-core/src/airflow/models/callback.py
b/airflow-core/src/airflow/models/callback.py
index 352885a420e..6295b7dfa7b 100644
--- a/airflow-core/src/airflow/models/callback.py
+++ b/airflow-core/src/airflow/models/callback.py
@@ -144,7 +144,7 @@ class Callback(Base, BaseWorkload):
created_at: Mapped[datetime] = mapped_column(UtcDateTime,
default=timezone.utcnow, nullable=False)
# Used for callbacks of type CallbackType.TRIGGERER
- trigger_id: Mapped[int] = mapped_column(Integer, ForeignKey("trigger.id"),
nullable=True)
+ trigger_id: Mapped[int | None] = mapped_column(Integer,
ForeignKey("trigger.id"), nullable=True)
trigger = relationship("Trigger", back_populates="callback", uselist=False)
def __init__(self, priority_weight: int = 1, prefix: str = "", **kwargs):
diff --git a/airflow-core/src/airflow/models/dagrun.py
b/airflow-core/src/airflow/models/dagrun.py
index 46fbebb0800..725bf09c77d 100644
--- a/airflow-core/src/airflow/models/dagrun.py
+++ b/airflow-core/src/airflow/models/dagrun.py
@@ -286,7 +286,7 @@ class DagRun(Base, LoggingMixin):
# This is nullable because it's too costly to migrate dagruns created prior
# to this column's addition (Airflow 3.2.0). If you want a reasonable
# meaningful non-null value, use ``dr.created_at or dr.run_after``.
- created_at: Mapped[datetime] = mapped_column(UtcDateTime, nullable=True,
default=timezone.utcnow)
+ created_at: Mapped[datetime | None] = mapped_column(UtcDateTime,
nullable=True, default=timezone.utcnow)
updated_at: Mapped[datetime | None] = mapped_column(
UtcDateTime, default=timezone.utcnow, onupdate=timezone.utcnow,
nullable=True
)
diff --git a/scripts/ci/prek/check_mapped_column_nullable_annotations.py
b/scripts/ci/prek/check_mapped_column_nullable_annotations.py
new file mode 100755
index 00000000000..f3b41f7917f
--- /dev/null
+++ b/scripts/ci/prek/check_mapped_column_nullable_annotations.py
@@ -0,0 +1,227 @@
+#!/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.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())
diff --git
a/scripts/tests/ci/prek/test_check_mapped_column_nullable_annotations.py
b/scripts/tests/ci/prek/test_check_mapped_column_nullable_annotations.py
new file mode 100644
index 00000000000..95bed4307af
--- /dev/null
+++ b/scripts/tests/ci/prek/test_check_mapped_column_nullable_annotations.py
@@ -0,0 +1,235 @@
+# 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.
+from __future__ import annotations
+
+import re
+import textwrap
+
+import pytest
+from ci.prek import check_mapped_column_nullable_annotations as hook
+from ci.prek.check_mapped_column_nullable_annotations import check_files,
iter_mismatches, main
+
+MODEL_PREAMBLE = """\
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Optional, Union
+
+from sqlalchemy import DateTime, Integer, orm
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
+
+IS_NULLABLE = True
+
+
+class Base(DeclarativeBase):
+ pass
+
+
+class Model(Base):
+ __tablename__ = "model"
+ id: Mapped[int] = mapped_column(Integer, primary_key=True)
+"""
+COLUMN_LINE = MODEL_PREAMBLE.count("\n") + 1
+_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
+
+
+def model_with(attribute_line: str) -> str:
+ return MODEL_PREAMBLE + textwrap.indent(textwrap.dedent(attribute_line), "
")
+
+
+class TestIterMismatches:
+ @pytest.mark.parametrize(
+ ("attribute_line", "expected_annotation"),
+ [
+ pytest.param(
+ "created_at: Mapped[datetime] = mapped_column(DateTime,
nullable=True)",
+ "Mapped[datetime]",
+ id="plain",
+ ),
+ pytest.param(
+ "trigger_id: Mapped[int] = mapped_column(Integer,
nullable=True, default=None)",
+ "Mapped[int]",
+ id="other-kwargs",
+ ),
+ pytest.param(
+ 'created_at: Mapped["datetime"] = mapped_column(DateTime,
nullable=True)',
+ "Mapped['datetime']",
+ id="string-inner",
+ ),
+ pytest.param(
+ 'created_at: "Mapped[datetime]" = mapped_column(DateTime,
nullable=True)',
+ "Mapped[datetime]",
+ id="string-whole",
+ ),
+ pytest.param(
+ "created_at: orm.Mapped[datetime] =
orm.mapped_column(DateTime, nullable=True)",
+ "orm.Mapped[datetime]",
+ id="qualified",
+ ),
+ ],
+ )
+ def test_reports_nullable_column_whose_annotation_cannot_be_none(
+ self, write_python_file, attribute_line, expected_annotation
+ ):
+ path = write_python_file(model_with(attribute_line))
+
+ mismatches = list(iter_mismatches(path))
+
+ assert len(mismatches) == 1
+ mismatch = mismatches[0]
+ assert mismatch.path == path
+ assert mismatch.lineno == COLUMN_LINE
+ assert mismatch.attribute == attribute_line.split(":")[0]
+ assert mismatch.annotation == expected_annotation
+
+ @pytest.mark.parametrize(
+ "annotation",
+ [
+ "Mapped[datetime | None]",
+ "Mapped[None | datetime]",
+ "Mapped[Optional[datetime]]",
+ "Mapped[Union[datetime, None]]",
+ "Mapped[Union[None, datetime]]",
+ "Mapped[Any]",
+ "Mapped[dict[str, Any] | None]",
+ 'Mapped["datetime | None"]',
+ '"Mapped[datetime | None]"',
+ ],
+ )
+ def test_accepts_annotations_that_admit_none(self, write_python_file,
annotation):
+ path = write_python_file(
+ model_with(f"created_at: {annotation} = mapped_column(DateTime,
nullable=True)")
+ )
+
+ assert list(iter_mismatches(path)) == []
+
+ @pytest.mark.parametrize(
+ "attribute_line",
+ [
+ pytest.param("created_at: Mapped[datetime] =
mapped_column(DateTime)", id="nullable-derived"),
+ pytest.param(
+ "created_at: Mapped[datetime] = mapped_column(DateTime,
nullable=False)", id="not-nullable"
+ ),
+ pytest.param(
+ "created_at: Mapped[datetime] = mapped_column(DateTime,
nullable=IS_NULLABLE)",
+ id="non-literal-nullable",
+ ),
+ pytest.param(
+ "created_at: Mapped[datetime | None] = mapped_column(DateTime,
nullable=False)",
+ id="optional-on-not-null-is-not-reported",
+ ),
+ pytest.param("children: Mapped[list[Model]] =
relationship(Model)", id="relationship"),
+ pytest.param("created_at = mapped_column(DateTime,
nullable=True)", id="unannotated"),
+ pytest.param("created_at: Mapped[datetime]", id="no-value"),
+ pytest.param("count: int = 0", id="not-mapped"),
+ ],
+ )
+ def test_ignores_attributes_outside_the_rule(self, write_python_file,
attribute_line):
+ path = write_python_file(model_with(attribute_line))
+
+ assert list(iter_mismatches(path)) == []
+
+ def test_reports_multiline_call_at_the_annotation_line(self,
write_python_file):
+ path = write_python_file(
+ model_with(
+ """\
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime,
+ default=datetime.now,
+ nullable=True,
+ )
+ """
+ )
+ )
+
+ assert [(m.attribute, m.lineno) for m in iter_mismatches(path)] ==
[("updated_at", COLUMN_LINE)]
+
+ def test_reports_every_mismatch_in_a_file(self, write_python_file):
+ path = write_python_file(
+ model_with(
+ """\
+ created_at: Mapped[datetime] = mapped_column(DateTime,
nullable=True)
+ updated_at: Mapped[datetime | None] = mapped_column(DateTime,
nullable=True)
+ trigger_id: Mapped[int] = mapped_column(Integer, nullable=True)
+ """
+ )
+ )
+
+ assert [m.attribute for m in iter_mismatches(path)] == ["created_at",
"trigger_id"]
+
+ def test_skips_files_without_mapped_column(self, write_python_file):
+ path = write_python_file("created_at: Mapped[datetime] =
Column(DateTime, nullable=True)\n")
+
+ assert list(iter_mismatches(path)) == []
+
+ def test_skips_unparsable_and_missing_files(self, write_python_file,
tmp_path):
+ broken = write_python_file("created_at: Mapped[datetime] =
mapped_column(DateTime, nullable=True\n")
+
+ assert list(iter_mismatches(broken)) == []
+ assert list(iter_mismatches(tmp_path / "missing.py")) == []
+
+
+class TestCheckFiles:
+ def test_reports_each_mismatch_with_location_and_fails(self,
write_python_file, capsys):
+ path = write_python_file(
+ model_with("created_at: Mapped[datetime] = mapped_column(DateTime,
nullable=True)")
+ )
+
+ assert check_files([path]) == 1
+
+ output = _ANSI_RE.sub("", capsys.readouterr().out)
+ assert f"{path}:{COLUMN_LINE}" in output
+ assert "created_at" in output
+ assert "Mapped[X | None]" in output
+
+ def test_passes_silently_when_clean(self, write_python_file, capsys):
+ path = write_python_file(
+ model_with("created_at: Mapped[datetime | None] =
mapped_column(DateTime, nullable=True)")
+ )
+
+ assert check_files([path]) == 0
+ assert capsys.readouterr().out == ""
+
+
+class TestMain:
+ def test_checks_given_files(self, write_python_file):
+ path = write_python_file(
+ model_with("created_at: Mapped[datetime] = mapped_column(DateTime,
nullable=True)")
+ )
+
+ assert main([str(path)]) == 1
+
+ def test_no_files_is_a_noop(self):
+ assert main([]) == 0
+
+ def test_all_files_walks_scan_roots_and_skips_hidden_dirs(self, tmp_path,
monkeypatch):
+ monkeypatch.setattr(hook, "REPO_ROOT", tmp_path)
+ monkeypatch.setattr(hook, "SCAN_ROOTS", ("dist",))
+ bad = model_with("created_at: Mapped[datetime] =
mapped_column(DateTime, nullable=True)")
+ for relative in (
+ "dist/src/pkg/models.py",
+ "dist/.venv/lib/models.py",
+ "dist/node_modules/x/models.py",
+ ):
+ target = tmp_path / relative
+ target.parent.mkdir(parents=True)
+ target.write_text(bad)
+
+ assert main(["--all-files"]) == 1
+ assert [str(path.relative_to(tmp_path)) for path in
hook.iter_python_files([tmp_path / "dist"])] == [
+ "dist/src/pkg/models.py"
+ ]