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

kaxil 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 d79c0b18f35 Fix ORM models failing to configure in processes that skip 
database setup (#74642)
d79c0b18f35 is described below

commit d79c0b18f35d025e14a62aab9ee288230245eb83
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Oct 14 11:14:27 2026 +0100

    Fix ORM models failing to configure in processes that skip database setup 
(#74642)
    
    Models name each other in string relationships, which only resolve once
    every model module is imported. The before_configured hook that imports
    them all was registered in configure_orm(), so a process that skips it
    (virtualenv-mode subprocesses, a task re-executed for run_as_user,
    non-DB tests) or configures mappers before it runs (local settings)
    only worked if it had already imported the right model modules. If it
    had not, building a model failed, for example with Team.dag_bundles
    unable to find DagBundleModel.
    
    Register the hook in airflow.models.base, before any model class is
    defined, and drop the test-only copy from the pytest plugin. Remove the
    orm_event_handlers test: it guarded the line that moved, and now passes
    whatever the code does.
---
 airflow-core/src/airflow/models/base.py            | 11 +++++-
 .../src/airflow/utils/orm_event_handlers.py        |  4 --
 airflow-core/tests/unit/models/test_base.py        | 43 +++++++++++++++++++++-
 .../tests/unit/utils/test_orm_event_handlers.py    | 37 -------------------
 devel-common/src/tests_common/pytest_plugin.py     | 15 --------
 5 files changed, 50 insertions(+), 60 deletions(-)

diff --git a/airflow-core/src/airflow/models/base.py 
b/airflow-core/src/airflow/models/base.py
index 1c7af7b275a..522753f76ba 100644
--- a/airflow-core/src/airflow/models/base.py
+++ b/airflow-core/src/airflow/models/base.py
@@ -19,10 +19,11 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING, Any
 
-from sqlalchemy import Integer, MetaData, String, text
-from sqlalchemy.orm import Mapped, mapped_column, registry
+from sqlalchemy import Integer, MetaData, String, event, text
+from sqlalchemy.orm import Mapped, Mapper, mapped_column, registry
 
 from airflow.configuration import conf
+from airflow.models import import_all_models
 
 SQL_ALCHEMY_SCHEMA = conf.get("database", "SQL_ALCHEMY_SCHEMA")
 
@@ -48,6 +49,12 @@ metadata = MetaData(schema=_get_schema(), 
naming_convention=naming_convention)
 mapper_registry = registry(metadata=metadata)
 _sentinel = object()
 
+# Models refer to each other by class name, such as 
relationship("DagBundleModel"), and a name resolves
+# only once its module is imported. Import every model before SQLAlchemy first 
configures mappers,
+# whether or not this process has a database engine. SQLAlchemy 2.0 offers 
this event only on the
+# Mapper class (2.1 adds one per registry), so it fires on whichever registry 
configures first.
+event.listen(Mapper, "before_configured", import_all_models, once=True)
+
 if TYPE_CHECKING:
     Base = Any
 else:
diff --git a/airflow-core/src/airflow/utils/orm_event_handlers.py 
b/airflow-core/src/airflow/utils/orm_event_handlers.py
index da42433dbb0..74d51576a5e 100644
--- a/airflow-core/src/airflow/utils/orm_event_handlers.py
+++ b/airflow-core/src/airflow/utils/orm_event_handlers.py
@@ -24,7 +24,6 @@ import traceback
 from uuid import uuid4
 
 from sqlalchemy import event, exc
-from sqlalchemy.orm import Mapper
 
 from airflow.configuration import conf
 
@@ -33,9 +32,6 @@ log = logging.getLogger(__name__)
 
 def setup_event_handlers(engine):
     """Setups event handlers."""
-    from airflow.models import import_all_models
-
-    event.listen(Mapper, "before_configured", import_all_models, once=True)
 
     @event.listens_for(engine, "connect")
     def connect(dbapi_connection, connection_record):
diff --git a/airflow-core/tests/unit/models/test_base.py 
b/airflow-core/tests/unit/models/test_base.py
index 51fd430dbab..e5b3728081f 100644
--- a/airflow-core/tests/unit/models/test_base.py
+++ b/airflow-core/tests/unit/models/test_base.py
@@ -16,15 +16,18 @@
 # under the License.
 from __future__ import annotations
 
+import os
+import subprocess
+import sys
+
 import pytest
 
 from airflow.models.base import get_id_collation_args
 
 from tests_common.test_utils.config import conf_vars
 
-pytestmark = pytest.mark.db_test
-
 
[email protected]_test
 @pytest.mark.parametrize(
     ("dsn", "expected", "extra"),
     [
@@ -50,3 +53,39 @@ pytestmark = pytest.mark.db_test
 def test_collation(dsn, expected, extra):
     with conf_vars({("database", "sql_alchemy_conn"): dsn, **extra}):
         assert expected == get_id_collation_args()
+
+
+def test_models_configure_in_process_that_skips_orm_setup():
+    # Models name each other in string relationships, which resolve only once 
every model is imported.
+    # This process never calls configure_orm(), so building the first model 
must import the rest.
+    code = "from airflow.models.connection import Connection; 
Connection(conn_id='test', conn_type='http')"
+
+    result = subprocess.run(
+        [sys.executable, "-c", code],
+        env={**os.environ, "PYTHON_OPERATORS_VIRTUAL_ENV_MODE": "1"},
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+
+    assert result.returncode == 0, result.stderr
+
+
+def test_local_settings_can_build_models_while_airflow_is_imported(tmp_path):
+    # airflow_local_settings is imported before configure_orm() runs.
+    (tmp_path / "airflow_local_settings.py").write_text(
+        "from sqlalchemy.orm import configure_mappers\n"
+        "from airflow.models.team import Team\n"
+        "configure_mappers()\n"
+    )
+    python_path = os.pathsep.join(filter(None, [str(tmp_path), 
os.environ.get("PYTHONPATH")]))
+
+    result = subprocess.run(
+        [sys.executable, "-c", "import airflow"],
+        env={**os.environ, "PYTHONPATH": python_path},
+        capture_output=True,
+        text=True,
+        check=False,
+    )
+
+    assert result.returncode == 0, result.stderr
diff --git a/airflow-core/tests/unit/utils/test_orm_event_handlers.py 
b/airflow-core/tests/unit/utils/test_orm_event_handlers.py
deleted file mode 100644
index 0b670c65662..00000000000
--- a/airflow-core/tests/unit/utils/test_orm_event_handlers.py
+++ /dev/null
@@ -1,37 +0,0 @@
-# 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.
-
-"""
-These tests ensure compatibility with the deprecation of 
`sqlalchemy.orm.mapper()` in SQLAlchemy 2.0.0.b1.
-See also: 
https://docs.sqlalchemy.org/en/21/orm/mapping_styles.html#orm-imperative-mapping
-"""
-
-from __future__ import annotations
-
-import contextlib
-import warnings
-
-from sqlalchemy.exc import InvalidRequestError, SADeprecationWarning
-
-from airflow.utils.orm_event_handlers import setup_event_handlers
-
-
-def test_setup_event_handlers_no_sa_deprecation_warning():
-    warnings.simplefilter("error", category=SADeprecationWarning)
-    with contextlib.suppress(InvalidRequestError):
-        # We are only interested in the warning that may be issued at the 
beginning of the method.
-        setup_event_handlers(engine=None)
diff --git a/devel-common/src/tests_common/pytest_plugin.py 
b/devel-common/src/tests_common/pytest_plugin.py
index 3cca6dec53c..c82b2644551 100644
--- a/devel-common/src/tests_common/pytest_plugin.py
+++ b/devel-common/src/tests_common/pytest_plugin.py
@@ -139,21 +139,6 @@ if skip_db_tests:
     os.environ["AIRFLOW__DATABASE__SQL_ALCHEMY_CONN"] = "bad_schema:///"
     # Set it here to pass the flag to python-xdist spawned processes
     os.environ["_AIRFLOW_SKIP_DB_TESTS"] = "true"
-    # configure_orm() never succeeds against the connection string above, so 
the hook it installs to
-    # import every model before SQLAlchemy configures mappers is missing. 
Install the same hook here,
-    # or a string relationship such as Team.dag_bundles cannot resolve when a 
test first builds an ORM
-    # object. Imported lazily: importing airflow here would initialize its 
settings before this module
-    # finishes setting the environment, and not every distribution's test 
environment has SQLAlchemy.
-    if importlib.util.find_spec("sqlalchemy"):
-        from sqlalchemy import event
-        from sqlalchemy.orm import Mapper
-
-        def _import_all_models() -> None:
-            from airflow.models import import_all_models
-
-            import_all_models()
-
-        event.listen(Mapper, "before_configured", _import_all_models, 
once=True)
 
 if run_db_tests_only:
     # Set it here to pass the flag to python-xdist spawned processes

Reply via email to