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

ephraimbuddy 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 786dd79c284 Keep ZIP-archived Dags active when dag_discovery_safe_mode 
is False (#68518)
786dd79c284 is described below

commit 786dd79c284af3930c6012743cc0fccb4280f717
Author: deepinsight coder <[email protected]>
AuthorDate: Mon Aug 17 05:55:17 2026 -0700

    Keep ZIP-archived Dags active when dag_discovery_safe_mode is False (#68518)
    
    * Keep ZIP-archived Dags active when dag_discovery_safe_mode is False
    
    The Dag file processor parsed and activated keyword-less Dags inside ZIP
    archives but then immediately deactivated them, because the scan that
    decides which files still exist ignored dag_discovery_safe_mode and always
    applied the airflow/dag keyword heuristic. Resolve the setting once and use
    it consistently for discovery and the deactivation scan, and log the
    effective value so a misconfigured processor is diagnosable.
    
    closes: #66104
    
    * Cover safe-mode filtering in Dag bundle discovery
    
    The file-discovery and observed-file paths must both honor safe mode so 
wrapped Dag files are not silently skipped by a future regression.
    
    * Shorten review-flagged comments in Dag discovery safe mode change
    
    Review asked for one-line comments and no issue numbers in code, since
    the PR link already carries that context.
    
    * Validate configured Dag discovery safe mode
    
    ---------
    
    Co-authored-by: Cursor <[email protected]>
---
 airflow-core/docs/core-concepts/dags.rst           |  2 +-
 airflow-core/newsfragments/68518.bugfix.rst        |  1 +
 .../src/airflow/config_templates/config.yml        | 13 +++-
 airflow-core/src/airflow/dag_processing/manager.py | 20 +++++-
 .../tests/unit/dag_processing/test_manager.py      | 84 ++++++++++++++++++++++
 5 files changed, 115 insertions(+), 5 deletions(-)

diff --git a/airflow-core/docs/core-concepts/dags.rst 
b/airflow-core/docs/core-concepts/dags.rst
index 91796b1bbca..5d2b4925345 100644
--- a/airflow-core/docs/core-concepts/dags.rst
+++ b/airflow-core/docs/core-concepts/dags.rst
@@ -166,7 +166,7 @@ While both Dag constructors get called when the file is 
accessed, only ``dag_1``
 
     When searching for Dags inside the Dag bundle, Airflow only considers 
Python files that contain the strings ``airflow`` and ``dag`` 
(case-insensitively) as an optimization.
 
-    To consider all Python files instead, disable the 
``DAG_DISCOVERY_SAFE_MODE`` configuration flag.
+    To consider all Python files instead, set the ``[core] 
dag_discovery_safe_mode`` configuration flag to ``False``. This is the setting 
to use when your Dags are defined through a wrapper or abstraction whose source 
does not contain those strings. The flag is read by the Dag file processor, so 
set it on that component (and on anything that runs ``airflow dags 
reserialize``) and restart it for the change to take effect -- otherwise Dags 
may appear after a manual reserialize and then dis [...]
 
 You can also provide an ``.airflowignore`` file inside your Dag bundle, or any 
of its subfolders, which describes patterns of files for the loader to ignore. 
It covers the directory it's in plus all subfolders underneath it. See  
:ref:`.airflowignore <concepts:airflowignore>` below for details of the file 
syntax.
 
diff --git a/airflow-core/newsfragments/68518.bugfix.rst 
b/airflow-core/newsfragments/68518.bugfix.rst
new file mode 100644
index 00000000000..db2792de216
--- /dev/null
+++ b/airflow-core/newsfragments/68518.bugfix.rst
@@ -0,0 +1 @@
+Keep Dags from ZIP archives active when ``[core] dag_discovery_safe_mode`` is 
``False``. Previously, Dags packaged in ZIP files whose source did not contain 
the ``airflow``/``dag`` keywords were parsed and activated by the Dag file 
processor but then immediately deactivated, because the scan that determines 
which files still exist always applied the keyword heuristic regardless of the 
configured ``dag_discovery_safe_mode``.
diff --git a/airflow-core/src/airflow/config_templates/config.yml 
b/airflow-core/src/airflow/config_templates/config.yml
index c065b277716..6d2438fd213 100644
--- a/airflow-core/src/airflow/config_templates/config.yml
+++ b/airflow-core/src/airflow/config_templates/config.yml
@@ -320,7 +320,18 @@ core:
       default: "True"
     dag_discovery_safe_mode:
       description: |
-        If enabled, Airflow will only scan files containing both ``DAG`` and 
``airflow`` (case-insensitive).
+        If enabled, Airflow only scans files containing both ``DAG`` and 
``airflow`` (case-insensitive)
+        when looking for Dags. Set this to ``False`` to scan every Python file 
instead -- required when
+        your Dags are defined through a wrapper or abstraction whose source 
does not contain those strings.
+
+        This setting is read by the Dag file processor, so it must be 
configured for that component. In a
+        separate-process deployment (for example the Helm chart) set it on the 
``dag-processor`` as well as
+        on anything that runs ``airflow dags reserialize``, and restart the 
component for a change to take
+        effect. If only some components see ``False``, Dags can appear after a 
manual reserialize and then
+        be deactivated again on the next processor scan.
+
+        As an alternative to scanning every file, set ``[core] 
might_contain_dag_callable`` to a custom
+        heuristic that recognizes your wrapper.
       version_added: 1.10.3
       type: string
       example: ~
diff --git a/airflow-core/src/airflow/dag_processing/manager.py 
b/airflow-core/src/airflow/dag_processing/manager.py
index e8a8a2c5036..395ec25b371 100644
--- a/airflow-core/src/airflow/dag_processing/manager.py
+++ b/airflow-core/src/airflow/dag_processing/manager.py
@@ -298,6 +298,11 @@ class DagFileProcessorManager(LoggingMixin):
         factory=_config_get_factory("dag_processor", "file_parsing_sort_mode")
     )
 
+    dag_discovery_safe_mode: bool = attrs.field(
+        factory=_config_bool_factory("core", "dag_discovery_safe_mode")
+    )
+    """Resolved once per process so file discovery and the deactivation scan 
use the same value."""
+
     _api_server: InProcessExecutionAPI = attrs.field(init=False, 
factory=_make_execution_api)
     """API server to interact with Metadata DB"""
 
@@ -955,8 +960,16 @@ class DagFileProcessorManager(LoggingMixin):
         """Get relative paths for dag files from bundle dir."""
         # Build up a list of Python files that could contain DAGs
         self.log.info("Searching for files in %s at %s", bundle.name, 
bundle.path)
-        rel_paths = [Path(x).relative_to(bundle.path) for x in 
list_py_file_paths(bundle.path)]
-        self.log.info("Found %s files for bundle %s", len(rel_paths), 
bundle.name)
+        rel_paths = [
+            Path(x).relative_to(bundle.path)
+            for x in list_py_file_paths(bundle.path, 
safe_mode=self.dag_discovery_safe_mode)
+        ]
+        self.log.info(
+            "Found %s files for bundle %s (dag_discovery_safe_mode=%s)",
+            len(rel_paths),
+            bundle.name,
+            self.dag_discovery_safe_mode,
+        )
 
         return rel_paths
 
@@ -974,7 +987,8 @@ class DagFileProcessorManager(LoggingMixin):
             try:
                 with zipfile.ZipFile(abs_path) as z:
                     for info in z.infolist():
-                        if might_contain_dag(info.filename, True, z):
+                        # Use the configured discovery safe mode
+                        if might_contain_dag(info.filename, 
self.dag_discovery_safe_mode, z):
                             yield os.path.join(abs_path, info.filename)
             except zipfile.BadZipFile:
                 self.log.exception("There was an error accessing ZIP file %s", 
abs_path)
diff --git a/airflow-core/tests/unit/dag_processing/test_manager.py 
b/airflow-core/tests/unit/dag_processing/test_manager.py
index 4599c8ea583..6d83d9c69ab 100644
--- a/airflow-core/tests/unit/dag_processing/test_manager.py
+++ b/airflow-core/tests/unit/dag_processing/test_manager.py
@@ -157,6 +157,40 @@ def 
_create_zip_bundle_with_valid_and_broken_dags(zip_path: Path) -> None:
         )
 
 
+def _create_zip_bundle_with_keywordless_dag(zip_path: Path) -> None:
+    """Build a zip with one keyword-bearing member and one keyword-less 
("wrapped") member.
+
+    ``with_keywords.py`` contains the ``airflow``/``dag`` strings the 
safe-mode heuristic looks
+    for. ``no_keywords.py`` mimics a custom wrapper whose source contains 
neither ``airflow`` nor
+    ``dag``/``asset`` -- exactly the case ``dag_discovery_safe_mode=False`` 
exists to support.
+    """
+    with zipfile.ZipFile(zip_path, "w") as zf:
+        zf.writestr(
+            "with_keywords.py",
+            textwrap.dedent(
+                """
+                from airflow.sdk import DAG
+
+                with DAG(dag_id="zip_with_keywords"):
+                    pass
+                """
+            ),
+        )
+        zf.writestr(
+            "no_keywords.py",
+            textwrap.dedent(
+                """
+                from mycompany.pipelines import flow
+
+
+                @flow(name="nightly")
+                def nightly():
+                    run_step("extract")
+                """
+            ),
+        )
+
+
 class TestDagFileProcessorManager:
     @pytest.fixture(autouse=True)
     def _disable_examples(self):
@@ -309,6 +343,56 @@ class TestDagFileProcessorManager:
             manager.sync_bundles()
         
mock_bundles_manager.return_value.sync_bundles_to_db.assert_called_once_with(deactivate_missing=False)
 
+    @pytest.mark.parametrize(
+        "safe_mode",
+        [
+            pytest.param(False, id="safe-mode-off-includes-keywordless"),
+            pytest.param(True, id="safe-mode-on-filters-keywordless"),
+        ],
+    )
+    def test_find_files_in_bundle_respects_dag_discovery_safe_mode(self, 
tmp_path, safe_mode):
+        (tmp_path / "with_keywords.py").write_text("from airflow.sdk import 
DAG\n")
+        (tmp_path / "no_keywords.py").write_text("from mycompany.pipelines 
import flow\n")
+        bundle = MagicMock(spec=BaseDagBundle)
+        bundle.name = "testing"
+        bundle.path = tmp_path
+
+        with conf_vars({("core", "dag_discovery_safe_mode"): str(safe_mode)}):
+            manager = DagFileProcessorManager(max_runs=1)
+
+        expected = {Path("with_keywords.py")}
+        if not safe_mode:
+            expected.add(Path("no_keywords.py"))
+        assert set(manager._find_files_in_bundle(bundle)) == expected
+
+    @pytest.mark.parametrize(
+        "safe_mode",
+        [
+            pytest.param(False, id="safe-mode-off-includes-keywordless"),
+            pytest.param(True, id="safe-mode-on-filters-keywordless"),
+        ],
+    )
+    def test_get_observed_filelocs_respects_dag_discovery_safe_mode(self, 
tmp_path, safe_mode):
+        """ZIP-member discovery used for deactivation must honor the 
configured safe_mode.
+
+        With ``dag_discovery_safe_mode=False`` a keyword-less (wrapped) zip 
member is parsed and
+        activated, so it must also be reported as observed -- otherwise it is 
deactivated right
+        after being parsed. This path previously hardcoded safe_mode=True.
+        """
+        zip_path = tmp_path / "test_zip.zip"
+        _create_zip_bundle_with_keywordless_dag(zip_path)
+
+        with conf_vars({("core", "dag_discovery_safe_mode"): str(safe_mode)}):
+            manager = DagFileProcessorManager(max_runs=1)
+        observed_filelocs = manager._get_observed_filelocs(
+            {DagFileInfo(bundle_name="testing", rel_path=Path("test_zip.zip"), 
bundle_path=tmp_path)}
+        )
+
+        expected = {"test_zip.zip/with_keywords.py"}
+        if not safe_mode:
+            expected.add("test_zip.zip/no_keywords.py")
+        assert observed_filelocs == expected
+
     @pytest.mark.usefixtures("clear_parse_import_errors")
     def test_refresh_dag_bundles_keeps_zip_inner_file_errors(self, session, 
tmp_path, configure_dag_bundles):
         bundle_path = tmp_path / "bundleone"

Reply via email to