This is an automated email from the ASF dual-hosted git repository.

shahar1 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 675b02a2f9b Make airflow-core typing hold for SQLAlchemy 2.0 and 2.1 
(#74629)
675b02a2f9b is described below

commit 675b02a2f9b21b77777b1577b43e37dbf2513e37
Author: Dev-iL <[email protected]>
AuthorDate: Sun Oct 18 12:23:13 2026 +0300

    Make airflow-core typing hold for SQLAlchemy 2.0 and 2.1 (#74629)
    
    SQLAlchemy 2.1 turns Select/Row/Result into TypeVarTuple generics, so
    `Select[tuple[X]]` now means a one-column select of a tuple, and rows
    unpack to the real (possibly nullable) column types instead of Any.
    
    No parameterised `Select` spelling is valid on both versions, so helpers
    that only refine a select they receive are generic over it, and helpers
    that build their own select return an unparameterised `Select`. Nullable
    columns are narrowed with `is None` checks rather than cast; the pool and
    asset-event queries already exclude NULL there, while get_authorized_pools
    now skips pools whose name is NULL. The 12-column task instance row, beyond
    SQLAlchemy's typed select() overloads, is unpacked into locals declared
    with their column types.
    
    Type-checks with mypy on both SQLAlchemy 2.0.54 and 2.1.4.
---
 .../api_fastapi/auth/managers/base_auth_manager.py |  3 ++-
 .../core_api/services/ui/dependencies.py           |  6 +++++-
 .../execution_api/routes/task_instances.py         | 20 ++++++++++++++++---
 .../src/airflow/jobs/scheduler_job_runner.py       |  2 +-
 airflow-core/src/airflow/models/dynamic_region.py  | 23 +++++++++++++---------
 airflow-core/src/airflow/models/pool.py            |  2 +-
 .../src/airflow/models/task_coordinates.py         |  9 +++++----
 airflow-core/src/airflow/models/xcom.py            |  7 +++++--
 airflow-core/src/airflow/utils/sqlalchemy.py       |  3 +++
 .../auth/managers/test_base_auth_manager.py        | 17 ++++++++++++++++
 .../tests/unit/models/test_taskinstance.py         |  4 +++-
 11 files changed, 73 insertions(+), 23 deletions(-)

diff --git 
a/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py 
b/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
index c1d901a8bc8..77c0ef9a141 100644
--- a/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
+++ b/airflow-core/src/airflow/api_fastapi/auth/managers/base_auth_manager.py
@@ -786,7 +786,8 @@ class BaseAuthManager(Generic[T], LoggingMixin, 
metaclass=ABCMeta):
         rows = session.execute(stmt).all()
         pools_by_team: dict[str | None, set[str]] = defaultdict(set)
         for pool_name, team_name in rows:
-            pools_by_team[team_name].add(pool_name)
+            if pool_name is not None:
+                pools_by_team[team_name].add(pool_name)
 
         pool_names: set[str] = set()
         for team_name, team_pool_names in pools_by_team.items():
diff --git 
a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/dependencies.py 
b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/dependencies.py
index b48a2480502..eb2e665e889 100644
--- a/airflow-core/src/airflow/api_fastapi/core_api/services/ui/dependencies.py
+++ b/airflow-core/src/airflow/api_fastapi/core_api/services/ui/dependencies.py
@@ -417,7 +417,9 @@ def get_data_dependencies(
             
select(TaskInletAssetReference.dag_id).where(TaskInletAssetReference.asset_id 
== asset_id),
             
select(DagScheduleAssetReference.dag_id).where(DagScheduleAssetReference.asset_id
 == asset_id),
         )
-        connected_dag_ids = 
set(session.scalars(select(connected_dag_ids_query.subquery().c.dag_id)))
+        connected_dag_ids: set[str] = set(
+            
session.scalars(select(connected_dag_ids_query.subquery().c.dag_id))
+        )
         if not connected_dag_ids & readable_dag_ids:
             return {"nodes": [], "edges": []}
 
@@ -631,6 +633,8 @@ def get_data_dependencies(
             .distinct()
         )
         for event_asset_id, source_dag_id, source_task_id in alias_events:
+            if source_dag_id is None or source_task_id is None:
+                continue
             if readable_dag_ids is not None and source_dag_id not in 
readable_dag_ids:
                 continue
             asset_node_id = f"asset:{event_asset_id}"
diff --git 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
index 5b49827ca29..c27e2f74f6a 100644
--- 
a/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
+++ 
b/airflow-core/src/airflow/api_fastapi/execution_api/routes/task_instances.py
@@ -24,6 +24,7 @@ import itertools
 import json
 from collections import defaultdict
 from collections.abc import Callable, Collection, Iterator, Sequence
+from datetime import datetime
 from typing import Annotated, Any, NoReturn, cast
 from uuid import UUID
 
@@ -347,7 +348,7 @@ def ti_run(
         # backend.
 
         # However, do not clear it for deferral
-        xcom_keys = []
+        xcom_keys: list[str] = []
         if not ti.next_method:
             read = 
build_xcom_read_query(producer_ids=select(TI.id).where(TI.id == 
task_instance_id))
             entity = xcom_entity(read)
@@ -569,6 +570,19 @@ def ti_update_state(
         .with_for_update(of=TI)
         .execution_options(include_all_attempts=True)
     )
+    # A 12-column select exceeds SQLAlchemy's typed ``select()`` overloads, so 
the row is untyped.
+    previous_state: str | None
+    try_number: int
+    max_tries: int
+    dag_id: str
+    task_id: str
+    run_id: str
+    region_index: int
+    region_id: UUID
+    hostname: str | None
+    logical_date: datetime | None
+    owners: str | None
+    working_set: bool | None
     try:
         (
             previous_state,
@@ -583,7 +597,7 @@ def ti_update_state(
             logical_date,
             owners,
             working_set,
-        ) = session.execute(old).one()
+        ) = tuple(session.execute(old).one())
         log.debug(
             "Retrieved current task instance state",
             previous_state=previous_state,
@@ -1122,7 +1136,7 @@ def ti_skip_downstream(
         raise HTTPException(status.HTTP_400_BAD_REQUEST, str(error)) from error
     if not selects:
         return
-    selected_ids = set(session.scalars(union(*selects)))
+    selected_ids: set[UUID] = set(session.scalars(union(*selects)))
 
     # Don't overwrite tasks that are already executing or finished.
     # See: https://github.com/apache/airflow/issues/59378
diff --git a/airflow-core/src/airflow/jobs/scheduler_job_runner.py 
b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
index c5e7732f6d8..19662ae5a07 100644
--- a/airflow-core/src/airflow/jobs/scheduler_job_runner.py
+++ b/airflow-core/src/airflow/jobs/scheduler_job_runner.py
@@ -3588,7 +3588,7 @@ class SchedulerJobRunner(BaseJobRunner, LoggingMixin):
         count_result: int | None = session.scalar(statement)
         return count_result if count_result is not None else 0
 
-    previous_ti_metrics: dict[TaskInstanceState, dict[tuple[str, str, str], 
int]] = {}
+    previous_ti_metrics: dict[TaskInstanceState, dict[tuple[str, str, str | 
None], int]] = {}
 
     @provide_session
     def _emit_ti_metrics(self, *, session: Session = NEW_SESSION) -> None:
diff --git a/airflow-core/src/airflow/models/dynamic_region.py 
b/airflow-core/src/airflow/models/dynamic_region.py
index 494cd09a926..b2100d95734 100644
--- a/airflow-core/src/airflow/models/dynamic_region.py
+++ b/airflow-core/src/airflow/models/dynamic_region.py
@@ -21,7 +21,7 @@ import struct
 from collections.abc import Collection, Iterable, Iterator
 from datetime import datetime
 from functools import partial
-from typing import TYPE_CHECKING, Any
+from typing import TYPE_CHECKING, TypeVar
 from uuid import UUID
 
 import attrs
@@ -72,6 +72,11 @@ if TYPE_CHECKING:
     from sqlalchemy.sql.elements import ColumnElement
 
     from airflow.models.taskinstance import TaskInstance
+    from airflow.utils.sqlalchemy import UUIDSelect
+
+# Bound unparameterised: no ``Select[...]`` spelling accepts every arity on 
both SQLAlchemy 2.0 and 2.1.
+# TODO: parameterise the bound with the variadic Select once the minimum 
SQLAlchemy is 2.1.
+_SelectT = TypeVar("_SelectT", bound="Select")
 
 
 def is_regional(region_id: UUID, region_index: int) -> bool:
@@ -566,8 +571,8 @@ def loop_position(
 
 
 def build_loop_family_legs(
-    query: Select[Any], family_member: DynamicRegion, *, iteration: int | None 
= None
-) -> tuple[Select[Any], Select[Any]]:
+    query: _SelectT, family_member: DynamicRegion, *, iteration: int | None = 
None
+) -> tuple[_SelectT, _SelectT]:
     """
     Split ``query``, a selection from ``task_instance``, into the rows that 
belong to one loop family.
 
@@ -648,7 +653,7 @@ def _match_any(column, value: str | Collection[str]) -> 
ColumnElement[bool]:
 
 
 def _filter_producers(
-    query: Select,
+    query: _SelectT,
     *,
     dag_id: str,
     run_id: str,
@@ -658,7 +663,7 @@ def _filter_producers(
     region_id: UUID | None,
     region_index: int | None,
     top_level_only: bool,
-) -> Select:
+) -> _SelectT:
     """Push every coordinate predicate that does not need loop ancestry into 
SQL."""
     from airflow.models.taskinstance import TaskInstance
 
@@ -707,7 +712,7 @@ def _validate_producer_request(
 
 
 def _build_candidate_query(
-    query: Select[Any],
+    query: _SelectT,
     *,
     dag_id: str,
     run_id: str,
@@ -718,7 +723,7 @@ def _build_candidate_query(
     region_id: UUID | None,
     region_index: int | None,
     session: Session,
-) -> Select[Any] | CompoundSelect | None:
+) -> _SelectT | CompoundSelect | None:
     """
     Narrow ``query`` to the candidate producers of the caller's scope, or 
``None`` if there are none.
 
@@ -823,7 +828,7 @@ def select_current_producer_ids(
     region_id: UUID | None = None,
     region_index: int | None = None,
     session: Session,
-) -> Select[tuple[UUID]]:
+) -> UUIDSelect:
     """
     Select the ids of the live producers :func:`resolve_current_producers` 
would return.
 
@@ -928,7 +933,7 @@ def _build_pass_ownership_filter(
 
 def select_loop_producer_ids(
     *, dag_id: str, run_id: str, loop_node_id: str, task_id: str, is_mapped: 
bool, session: Session
-) -> Select[tuple[UUID]]:
+) -> UUIDSelect:
     """
     Select every live execution of ``task_id`` across all passes of the loop 
``loop_node_id``.
 
diff --git a/airflow-core/src/airflow/models/pool.py 
b/airflow-core/src/airflow/models/pool.py
index 3a2bc8f0922..e791704c95c 100644
--- a/airflow-core/src/airflow/models/pool.py
+++ b/airflow-core/src/airflow/models/pool.py
@@ -398,4 +398,4 @@ class Pool(Base):
         pool_names: list[str], *, session: Session = NEW_SESSION
     ) -> dict[str, str | None]:
         stmt = select(Pool.pool, 
Pool.team_name).where(Pool.pool.in_(pool_names))
-        return {pool: team_name for pool, team_name in session.execute(stmt)}
+        return {pool: team_name for pool, team_name in session.execute(stmt) 
if pool is not None}
diff --git a/airflow-core/src/airflow/models/task_coordinates.py 
b/airflow-core/src/airflow/models/task_coordinates.py
index 7ce280a31bd..3d97ef3eade 100644
--- a/airflow-core/src/airflow/models/task_coordinates.py
+++ b/airflow-core/src/airflow/models/task_coordinates.py
@@ -49,6 +49,7 @@ if TYPE_CHECKING:
     from sqlalchemy.sql.elements import ColumnElement
 
     from airflow.serialization.definitions.dag import SerializedDAG, 
SerializedOperator
+    from airflow.utils.sqlalchemy import UUIDSelect
 
 
 LOOP_GATE_OPERATOR = "LoopGateOperator"
@@ -361,7 +362,7 @@ class TaskCoordinateResolver:
         run_id: str,
         task_ids: Collection[str],
         slots: Collection[tuple[str, int]],
-    ) -> Select[tuple[UUID]]:
+    ) -> UUIDSelect:
         """Select, in one statement, the region-less task instances that the 
named tasks or slots address."""
         targets = []
         if task_ids:
@@ -730,7 +731,7 @@ class TaskCoordinateResolver:
 
     def select_skip_target_ids(
         self, *, caller: TaskInstance, task_id: str, map_indexes: int | None = 
None
-    ) -> Select[tuple[UUID]]:
+    ) -> UUIDSelect:
         """
         Select the task instance ids a skip request from ``caller`` reaches 
for ``task_id``.
 
@@ -765,7 +766,7 @@ class TaskCoordinateResolver:
             map_indexes=map_indexes,
         )
 
-    def _select_loop_producer_ids(self, request: _ProducerRequest) -> 
Select[tuple[UUID]]:
+    def _select_loop_producer_ids(self, request: _ProducerRequest) -> 
UUIDSelect:
         if TYPE_CHECKING:
             assert request.loop_node_id is not None
         return select_loop_producer_ids(
@@ -789,7 +790,7 @@ class TaskCoordinateResolver:
         map_indexes: int | Collection[int] | None = None,
         previous_iteration: bool = False,
         all_iterations: bool = False,
-    ) -> Select[tuple[UUID]]:
+    ) -> UUIDSelect:
         """Select the task instance ids :meth:`resolve` returns, without 
loading the producers."""
         if region_index is not None and region_id is None:
             raise ValueError("region_index requires an explicit producer 
region_id")
diff --git a/airflow-core/src/airflow/models/xcom.py 
b/airflow-core/src/airflow/models/xcom.py
index 544545fa04d..2054ba1368a 100644
--- a/airflow-core/src/airflow/models/xcom.py
+++ b/airflow-core/src/airflow/models/xcom.py
@@ -68,6 +68,9 @@ if TYPE_CHECKING:
     from airflow.models.dagrun import DagRun
     from airflow.models.taskinstance import TaskInstance
 
+    # TODO: use Select[XComModel] once the minimum SQLAlchemy is 2.1; 2.0 uses 
Select[tuple[XComModel]].
+    XComModelSelect = Select
+
 
 XCOM_RETURN_KEY = "return_value"
 
@@ -328,7 +331,7 @@ class _XComOperations:
         include_prior_dates: bool = False,
         limit: int | None = None,
         try_number: int | None = None,
-    ) -> Select[tuple[XComModel]]:
+    ) -> XComModelSelect:
         """
         Composes a query to get one or more XCom entries.
 
@@ -596,7 +599,7 @@ def _reject_projection_write(mapper, connection, target):
     raise TypeError("XCom read projection is read-only")
 
 
-def build_xcom_read_query(*, producer_ids: Select, key: str | None = None) -> 
Select[tuple[XComModel]]:
+def build_xcom_read_query(*, producer_ids: Select, key: str | None = None) -> 
XComModelSelect:
     model = get_xcom_model()
     entity = aliased(model, _filter_rows(model.__table__, 
producer_ids=producer_ids, key=key))
     return select(entity).execution_options(populate_existing=True)
diff --git a/airflow-core/src/airflow/utils/sqlalchemy.py 
b/airflow-core/src/airflow/utils/sqlalchemy.py
index 966217a68d0..18c5da08214 100644
--- a/airflow-core/src/airflow/utils/sqlalchemy.py
+++ b/airflow-core/src/airflow/utils/sqlalchemy.py
@@ -52,6 +52,9 @@ if TYPE_CHECKING:
 
     from airflow.typing_compat import Self
 
+    # TODO: use Select[UUID] once the minimum SQLAlchemy is 2.1; 2.0 uses 
Select[tuple[UUID]].
+    UUIDSelect = Select
+
 
 log = logging.getLogger(__name__)
 
diff --git 
a/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py 
b/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
index f25f4dbc9fd..49e3699f203 100644
--- 
a/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
+++ 
b/airflow-core/tests/unit/api_fastapi/auth/managers/test_base_auth_manager.py
@@ -38,6 +38,7 @@ from 
airflow.api_fastapi.auth.managers.models.resource_details import (
 from airflow.api_fastapi.auth.tokens import JWTGenerator, JWTValidator
 from airflow.api_fastapi.common.types import MenuItem
 from airflow.exceptions import RemovedInAirflow4Warning
+from airflow.models.pool import Pool
 from airflow.models.team import Team
 
 from tests_common.test_utils.config import conf_vars
@@ -799,6 +800,22 @@ class TestBaseAuthManager:
         result = auth_manager.get_authorized_pools(user=user, session=session)
         assert result == expected
 
+    @pytest.mark.db_test
+    def test_get_authorized_pools_skips_null_names(self, auth_manager, 
session):
+        session.add_all(
+            [
+                Pool(pool=None, slots=1, include_deferred=False),
+                Pool(pool="authorized_pool", slots=1, include_deferred=False),
+            ]
+        )
+        session.flush()
+        auth_manager.is_authorized_pool = 
create_autospec(auth_manager.is_authorized_pool, return_value=True)
+
+        result = 
auth_manager.get_authorized_pools(user=Mock(spec=BaseAuthManagerUserTest), 
session=session)
+
+        assert None not in result
+        assert "authorized_pool" in result
+
     @pytest.mark.parametrize(
         ("authorized_uri_prefix", "rows", "expected"),
         [
diff --git a/airflow-core/tests/unit/models/test_taskinstance.py 
b/airflow-core/tests/unit/models/test_taskinstance.py
index 4a7721b3a41..d2bec4dc393 100644
--- a/airflow-core/tests/unit/models/test_taskinstance.py
+++ b/airflow-core/tests/unit/models/test_taskinstance.py
@@ -137,6 +137,8 @@ from tests_common.test_utils.taskinstance import (
 from unit.models import DEFAULT_DATE
 
 if TYPE_CHECKING:
+    from collections.abc import Sequence
+
     from tests_common.pytest_plugin import DagMaker
 
 pytestmark = [pytest.mark.db_test, pytest.mark.need_serialized_dag, 
pytest.mark.want_activate_assets]
@@ -650,7 +652,7 @@ class TestTaskInstance:
         )
 
         ti.run(session=session)
-        executor_configs = session.scalars(
+        executor_configs: Sequence[dict | None] = session.scalars(
             select(TaskInstance.executor_config).where(TaskInstance.dag_id == 
ti.dag_id)
         ).all()
         assert executor_configs == [{"foo": "bar"}]

Reply via email to