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

potiuk pushed a commit to branch v3-3-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v3-3-test by this push:
     new 3426221b7f9 [v3-3-test] Keep ZIP-archived Dags active when 
dag_discovery_safe_mode is False (#68518) (#71714)
3426221b7f9 is described below

commit 3426221b7f9de498b3698c2f1c08920ace7ddfe5
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Mon Aug 17 21:57:58 2026 +0200

    [v3-3-test] Keep ZIP-archived Dags active when dag_discovery_safe_mode is 
False (#68518) (#71714)
    
    * 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
    
    ---------
    (cherry picked from commit 786dd79c284af3930c6012743cc0fccb4280f717)
    
    Co-authored-by: deepinsight coder 
<[email protected]>
    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 cf7d6ee4716..9b90be76710 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 27d674b5802..42cefc13ba7 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 f0992e4494f..b214c0029e7 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"""
 
@@ -951,8 +956,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
 
@@ -970,7 +983,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 0bce3308c04..15dc8975e85 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