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

dabla 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 ce0a1c474c9 add WasbDagBundle to load Dags from Azure Blob Storage 
(#67016)
ce0a1c474c9 is described below

commit ce0a1c474c9493ccb574f97f7e27bdc967e4cc95
Author: Nishita Matlani <[email protected]>
AuthorDate: Thu Oct 15 17:38:43 2026 -0400

    add WasbDagBundle to load Dags from Azure Blob Storage (#67016)
    
    * add WasbDagBundle to load Dags from Azure Blob Storage
    
    * Use ValueError and NotADirectoryError in WasbDagBundle instead of 
AirflowException
    
    * Docs: Add WasbDagBundle guide to Microsoft Azure provider
    
    * Update 
providers/microsoft/azure/src/airflow/providers/microsoft/azure/bundles/wasb.py
    
    Co-authored-by: Yuseok Jo <[email protected]>
    
    * Add WasbHook.check_for_container tests and fix WasbDagBundle view URL 
tests
    
    * Apply ruff-format to WasbHook tests
    
    * Use ContainerClient.exists() in WasbHook.check_for_container
    
    * Add sync_to_local_dir tests for WasbHook
    
    * Use cached_property for WasbDagBundle.wasb_hook and simplify 
view_url_template
    
    * Fix misleading past-tense log messages in WasbHook stale-file cleanup
    
    The debug logs claimed a file or directory had been deleted before
    item.unlink()/os.rmdir() actually ran, so a failed deletion would still
    be logged as successful.
    
    Co-authored-by: Cursor <[email protected]>
    
    * Fix test_sync_to_local_dir_behaviour after WasbHook log wording change
    
    The previous commit changed the stale-file/dir deletion log messages
    from past to present tense but missed updating this test's assertions,
    breaking CI across every provider compatibility matrix job.
    
    Co-authored-by: Cursor <[email protected]>
    
    * Guard WasbHook.sync_to_local_dir against sibling-prefix blob names
    
    * Fix WasbDagBundle view URL breaking when using a SAS token
    
    blob_service_client.url includes the SAS query string when the
    connection is authenticated via a full SAS URL. Appending the
    container name and prefix as plain string suffixes placed them after
    the query string instead of in the path, producing a broken link.
    Build the URL by rejoining the path and query components instead.
    
    * Prevent WasbDagBundle from exposing SAS tokens and writing outside its 
directory
    
    A SAS-authenticated connection put its signature into the bundle view URL
    shown to every UI user, and a crafted blob name could be written outside the
    bundle directory on the Dag processor host. The prefix existence check also
    accepted sibling prefixes, letting a bundle initialize and then sync 
nothing.
    
    * Spec blob mocks in WasbHook sync tests
    
    Unspecced mocks accept any attribute, so the sync tests would keep passing
    even if the hook started reading a BlobProperties field that does not exist.
    
    * Use datetime.UTC in WasbHook tests (ruff UP017)
    
    * Restore uv.lock to main's version
    
    The previous commit rewrote uv.lock with an older uv, which lowered the
    lock revision and dropped the exclude-newer options. This PR adds no
    dependency, so the lock file matches main again.
    
    ---------
    
    Co-authored-by: Yuseok Jo <[email protected]>
    Co-authored-by: Cursor <[email protected]>
    Co-authored-by: David Blain <[email protected]>
---
 .../administration-and-deployment/dag-bundles.rst  |   3 +
 providers/microsoft/azure/docs/bundles/index.rst   |  80 ++++++
 providers/microsoft/azure/docs/index.rst           |   1 +
 providers/microsoft/azure/provider.yaml            |   5 +
 .../providers/microsoft/azure/bundles/__init__.py  |  16 ++
 .../providers/microsoft/azure/bundles/wasb.py      | 154 ++++++++++++
 .../providers/microsoft/azure/get_provider_info.py |   6 +
 .../providers/microsoft/azure/hooks/wasb.py        | 108 ++++++++
 .../tests/unit/microsoft/azure/bundles/__init__.py |  16 ++
 .../unit/microsoft/azure/bundles/test_wasb.py      | 279 +++++++++++++++++++++
 .../tests/unit/microsoft/azure/hooks/test_wasb.py  | 160 ++++++++++++
 11 files changed, 828 insertions(+)

diff --git a/airflow-core/docs/administration-and-deployment/dag-bundles.rst 
b/airflow-core/docs/administration-and-deployment/dag-bundles.rst
index 26876c858bf..67e14fd01fc 100644
--- a/airflow-core/docs/administration-and-deployment/dag-bundles.rst
+++ b/airflow-core/docs/administration-and-deployment/dag-bundles.rst
@@ -56,6 +56,9 @@ Airflow supports multiple types of Dag Bundles, each catering 
to specific use ca
 **airflow.providers.google.cloud.bundles.gcs.GCSDagBundle**
     These bundles reference a GCS bucket containing Dag files. They do not 
support versioning of the bundle, meaning tasks always run using the latest 
code.
 
+**airflow.providers.microsoft.azure.bundles.wasb.WasbDagBundle**
+    These bundles reference an Azure Blob Storage container containing Dag 
files. They do not support versioning of the bundle, meaning tasks always run 
using the latest code.
+
 Configuring Dag bundles
 -----------------------
 
diff --git a/providers/microsoft/azure/docs/bundles/index.rst 
b/providers/microsoft/azure/docs/bundles/index.rst
new file mode 100644
index 00000000000..89d4eeec146
--- /dev/null
+++ b/providers/microsoft/azure/docs/bundles/index.rst
@@ -0,0 +1,80 @@
+ .. 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.
+
+Bundles
+#######
+
+Dag bundles allow Airflow to load Dags from external sources. For a general 
overview see
+:doc:`apache-airflow:administration-and-deployment/dag-bundles`.
+
+WasbDagBundle
+=============
+
+Use the :class:`~airflow.providers.microsoft.azure.bundles.wasb.WasbDagBundle` 
to configure an Azure Blob
+Storage bundle in your Airflow's ``[dag_processor] dag_bundle_config_list``. 
The bundle does not support
+versioning; tasks always use the latest blobs synced to the local bundle 
directory.
+
+Example of using the WasbDagBundle:
+
+**JSON format example**:
+
+.. code-block:: bash
+
+    export AIRFLOW__DAG_PROCESSOR__DAG_BUNDLE_CONFIG_LIST='[
+     {
+         "name": "my-wasb-dags",
+         "classpath": 
"airflow.providers.microsoft.azure.bundles.wasb.WasbDagBundle",
+         "kwargs": {
+             "wasb_conn_id": "wasb_default",
+             "container_name": "airflow-dags",
+             "prefix": "dags/",
+             "refresh_interval": 60
+         }
+     }
+    ]'
+
+Authentication
+--------------
+
+The bundle uses a ``wasb`` Connection (``wasb_conn_id``). Authentication is 
the same as for
+:class:`~airflow.providers.microsoft.azure.hooks.wasb.WasbHook` — see 
:doc:`../connections/wasb`. On Azure-hosted
+Airflow, managed identity via ``DefaultAzureCredential`` is typical.
+
+Permissions
+-----------
+
+The identity needs read access to list and download blobs in the target 
container. Assign
+`Storage Blob Data Reader 
<https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/storage#storage-blob-data-reader>`_
+at the storage account or container scope.
+
+Container and prefix
+--------------------
+
+Set ``container_name`` to the blob container that holds your Dag files. Use 
``prefix`` for an optional
+virtual folder inside the container.
+
+Networking
+----------
+
+The Dag processor needs outbound HTTPS to the blob endpoint. Storage firewalls 
and private endpoints
+must allow access from Airflow, as for any WASB client.
+
+Reusing the Connection in Dags
+------------------------------
+
+You can use the same ``wasb`` Connection ID in ``wasb_conn_id`` for the bundle 
and for operators or sensors
+that use ``WasbHook``.
diff --git a/providers/microsoft/azure/docs/index.rst 
b/providers/microsoft/azure/docs/index.rst
index c918875943a..9d6277fbcef 100644
--- a/providers/microsoft/azure/docs/index.rst
+++ b/providers/microsoft/azure/docs/index.rst
@@ -34,6 +34,7 @@
     :maxdepth: 1
     :caption: Guides
 
+    Bundles <bundles/index>
     Connection types <connections/index>
     Email backend <email-backend>
     Message queues <message-queues/index>
diff --git a/providers/microsoft/azure/provider.yaml 
b/providers/microsoft/azure/provider.yaml
index 3bc067c8c38..c2130f80bb7 100644
--- a/providers/microsoft/azure/provider.yaml
+++ b/providers/microsoft/azure/provider.yaml
@@ -346,6 +346,11 @@ hooks:
     python-modules:
       - airflow.providers.microsoft.azure.hooks.powerbi
 
+bundles:
+  - integration-name: Microsoft Azure Blob Storage
+    python-modules:
+      - airflow.providers.microsoft.azure.bundles.wasb
+
 triggers:
   - integration-name: Microsoft Azure Analysis Services
     python-modules:
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/bundles/__init__.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/bundles/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/bundles/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/bundles/wasb.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/bundles/wasb.py
new file mode 100644
index 00000000000..aad595fb715
--- /dev/null
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/bundles/wasb.py
@@ -0,0 +1,154 @@
+# 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 os
+from functools import cached_property
+from pathlib import Path
+from urllib.parse import urlsplit, urlunsplit
+
+import structlog
+
+from airflow.dag_processing.bundles.base import BaseDagBundle
+from airflow.providers.microsoft.azure.hooks.wasb import WasbHook
+
+
+class WasbDagBundle(BaseDagBundle):
+    """
+    WASB Dag bundle - exposes a directory in Azure Blob Storage as a Dag 
bundle.
+
+    This allows Airflow to load Dags directly from an Azure Blob Storage 
container.
+
+    :param wasb_conn_id: Airflow connection ID for Azure Blob Storage. 
Defaults to WasbHook.default_conn_name.
+    :param container_name: The name of the blob container containing the Dag 
files.
+    :param prefix: Optional subdirectory within the container where the Dags 
are stored.
+        If empty, Dags are assumed to be at the root of the container.
+    """
+
+    supports_versioning = False
+
+    def __init__(
+        self,
+        *,
+        wasb_conn_id: str = WasbHook.default_conn_name,
+        container_name: str,
+        prefix: str = "",
+        **kwargs,
+    ) -> None:
+        super().__init__(**kwargs)
+        self.wasb_conn_id = wasb_conn_id
+        self.container_name = container_name
+        self.prefix = prefix
+        self.wasb_dags_dir: Path = self.base_dir
+
+        log = structlog.get_logger(__name__)
+        self._log = log.bind(
+            bundle_name=self.name,
+            version=self.version,
+            container_name=self.container_name,
+            prefix=self.prefix,
+            wasb_conn_id=self.wasb_conn_id,
+        )
+
+    def _initialize(self):
+        with self.lock():
+            if not self.wasb_dags_dir.exists():
+                self._log.info("Creating local Dags directory: %s", 
self.wasb_dags_dir)
+                os.makedirs(self.wasb_dags_dir)
+
+            if not self.wasb_dags_dir.is_dir():
+                raise NotADirectoryError(f"Local Dags path: 
{self.wasb_dags_dir} is not a directory.")
+
+            if not 
self.wasb_hook.check_for_container(container_name=self.container_name):
+                raise ValueError(f"WASB container '{self.container_name}' does 
not exist.")
+
+            if self.prefix:
+                if not self.wasb_hook.check_for_prefix(
+                    container_name=self.container_name, 
prefix=f"{self.prefix.rstrip('/')}/", delimiter="/"
+                ):
+                    raise ValueError(
+                        f"WASB prefix 
'wasb://{self.container_name}/{self.prefix}' does not exist."
+                    )
+            self.refresh()
+
+    def initialize(self) -> None:
+        self._initialize()
+        super().initialize()
+
+    @cached_property
+    def wasb_hook(self) -> WasbHook:
+        return WasbHook(wasb_conn_id=self.wasb_conn_id)
+
+    def __repr__(self):
+        return (
+            f"<WasbDagBundle("
+            f"name={self.name!r}, "
+            f"container_name={self.container_name!r}, "
+            f"prefix={self.prefix!r}, "
+            f"version={self.version!r}"
+            f")>"
+        )
+
+    def get_current_version(self) -> str | None:
+        """Return the current version of the Dag bundle. Currently not 
supported."""
+        return None
+
+    @property
+    def path(self) -> Path:
+        """Return the local path to the Dag files."""
+        return self.wasb_dags_dir
+
+    def refresh(self) -> None:
+        """Refresh the Dag bundle by re-downloading the Dags from Azure Blob 
Storage."""
+        if self.version:
+            raise ValueError("Refreshing a specific version is not supported")
+
+        with self.lock():
+            self._log.debug(
+                "Downloading Dags from wasb://%s/%s to %s",
+                self.container_name,
+                self.prefix,
+                self.wasb_dags_dir,
+            )
+            self.wasb_hook.sync_to_local_dir(
+                container_name=self.container_name,
+                prefix=self.prefix,
+                local_dir=self.wasb_dags_dir,
+                delete_stale=True,
+            )
+
+    def view_url(self, version: str | None = None) -> str | None:
+        """
+        Return a URL for viewing the Dags in Azure Blob Storage. Currently, 
versioning is not supported.
+
+        This method is deprecated and will be removed when the minimum 
supported Airflow version is 3.1.
+        Use `view_url_template` instead.
+        """
+        return self.view_url_template()
+
+    def view_url_template(self) -> str | None:
+        """Return a URL for viewing the Dags in Azure Blob Storage. Currently, 
versioning is not supported."""
+        if self.version:
+            raise ValueError("WASB url with version is not supported")
+        if hasattr(self, "_view_url_template") and self._view_url_template:
+            return self._view_url_template
+        account_url = self.wasb_hook.blob_service_client.url
+        scheme, netloc, path, _, _ = urlsplit(account_url)
+        path = f"{path.rstrip('/')}/{self.container_name}"
+        if self.prefix:
+            path = f"{path}/{self.prefix}"
+        return urlunsplit((scheme, netloc, path, "", ""))
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/get_provider_info.py
 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/get_provider_info.py
index 5a22d603e63..eac58f76d7b 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/get_provider_info.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/get_provider_info.py
@@ -319,6 +319,12 @@ def get_provider_info():
                 "python-modules": 
["airflow.providers.microsoft.azure.hooks.powerbi"],
             },
         ],
+        "bundles": [
+            {
+                "integration-name": "Microsoft Azure Blob Storage",
+                "python-modules": 
["airflow.providers.microsoft.azure.bundles.wasb"],
+            }
+        ],
         "triggers": [
             {
                 "integration-name": "Microsoft Azure Analysis Services",
diff --git 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/wasb.py 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/wasb.py
index c363c227690..8e90804208d 100644
--- 
a/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/wasb.py
+++ 
b/providers/microsoft/azure/src/airflow/providers/microsoft/azure/hooks/wasb.py
@@ -28,6 +28,7 @@ from __future__ import annotations
 
 import logging
 import os
+from pathlib import Path
 from typing import TYPE_CHECKING, Any, cast
 
 from azure.core.credentials import AzureSasCredential
@@ -274,6 +275,17 @@ class WasbHook(BaseHook):
         blobs = self.get_blobs_list(container_name=container_name, 
prefix=prefix, **kwargs)
         return bool(blobs)
 
+    def check_for_container(self, container_name: str) -> bool:
+        """
+        Check if a container exists on Azure Blob Storage.
+
+        :param container_name: Name of the container.
+        :return: True if the container exists, False otherwise.
+        """
+        container = self._get_container_client(container_name)
+        self.check_for_variable_type("container", container, ContainerClient)
+        return cast("ContainerClient", container).exists()
+
     def check_for_variable_type(self, variable_name: str, container: Any, 
expected_type: type[Any]) -> None:
         if not isinstance(container, expected_type):
             raise TypeError(
@@ -463,6 +475,102 @@ class WasbHook(BaseHook):
         # TODO: rework the interface as it might also return Awaitable
         return blob_client.download_blob(offset=offset, length=length, 
**kwargs)  # type: ignore[return-value]
 
+    def _sync_to_local_dir_delete_stale_local_files(
+        self, current_wasb_objects: list[Path], local_dir: Path
+    ) -> None:
+        current_wasb_keys = {key.resolve() for key in current_wasb_objects}
+
+        for item in local_dir.rglob("*"):
+            if item.is_file() and item.resolve() not in current_wasb_keys:
+                self.log.debug("Deleting stale local file: %s", item)
+                item.unlink()
+        for root, dirs, _ in os.walk(local_dir, topdown=False):
+            for d in dirs:
+                dir_path = os.path.join(root, d)
+                if not os.listdir(dir_path):
+                    self.log.debug("Deleting stale empty directory: %s", 
dir_path)
+                    os.rmdir(dir_path)
+
+    def _sync_to_local_dir_if_changed(
+        self, container_name: str, blob: BlobProperties, local_target_path: 
Path
+    ) -> None:
+        should_download = False
+        download_logs: list[str] = []
+        download_log_params: list[Any] = []
+
+        if not local_target_path.exists():
+            should_download = True
+            download_logs.append("Local file %s does not exist.")
+            download_log_params.append(local_target_path)
+        else:
+            local_stats = local_target_path.stat()
+            if blob.size != local_stats.st_size:
+                should_download = True
+                download_logs.append("Blob size (%s) and local file size (%s) 
differ.")
+                download_log_params.extend([blob.size, local_stats.st_size])
+
+            blob_last_modified = blob.last_modified
+            if blob_last_modified and local_stats.st_mtime < 
blob_last_modified.timestamp():
+                should_download = True
+                download_logs.append("Blob last modified (%s) and local file 
last modified (%s) differ.")
+                download_log_params.extend([blob_last_modified.timestamp(), 
local_stats.st_mtime])
+
+        if should_download:
+            self.get_file(
+                file_path=str(local_target_path),
+                container_name=container_name,
+                blob_name=blob.name,
+            )
+            download_logs.append("Downloaded %s to %s")
+            download_log_params.extend([blob.name, 
local_target_path.as_posix()])
+            self.log.debug(" ".join(download_logs), *download_log_params)
+        else:
+            self.log.debug(
+                "Local file %s is up-to-date with blob %s. Skipping download.",
+                local_target_path.as_posix(),
+                blob.name,
+            )
+
+    def sync_to_local_dir(
+        self,
+        container_name: str,
+        local_dir: Path,
+        prefix: str | None = None,
+        delete_stale: bool = True,
+    ) -> None:
+        """Download files from an Azure Blob Storage container to a local 
directory."""
+        self.log.debug("Downloading data from wasb://%s/%s to %s", 
container_name, prefix, local_dir)
+
+        local_wasb_objects: list[Path] = []
+        container = self._get_container_client(container_name)
+        self.check_for_variable_type("container", container, ContainerClient)
+        container = cast("ContainerClient", container)
+
+        local_dir_resolved = local_dir.resolve()
+        list_prefix = f"{prefix.rstrip('/')}/" if prefix else prefix
+        for blob in container.list_blobs(name_starts_with=list_prefix):
+            if blob.name.endswith("/"):
+                continue
+            blob_path = Path(blob.name)
+            if prefix:
+                local_target_path = 
local_dir.joinpath(blob_path.relative_to(prefix))
+            else:
+                local_target_path = local_dir.joinpath(blob_path)
+            if not 
local_target_path.resolve().is_relative_to(local_dir_resolved):
+                raise ValueError(f"Blob name {blob.name!r} resolves outside 
local directory {local_dir}")
+            if not local_target_path.parent.exists():
+                local_target_path.parent.mkdir(parents=True, exist_ok=True)
+                self.log.debug("Created local directory: %s", 
local_target_path.parent)
+            self._sync_to_local_dir_if_changed(
+                container_name=container_name, blob=blob, 
local_target_path=local_target_path
+            )
+            local_wasb_objects.append(local_target_path)
+
+        if delete_stale:
+            self._sync_to_local_dir_delete_stale_local_files(
+                current_wasb_objects=local_wasb_objects, local_dir=local_dir
+            )
+
     def create_container(self, container_name: str) -> None:
         """
         Create container object if not already existing.
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/bundles/__init__.py 
b/providers/microsoft/azure/tests/unit/microsoft/azure/bundles/__init__.py
new file mode 100644
index 00000000000..13a83393a91
--- /dev/null
+++ b/providers/microsoft/azure/tests/unit/microsoft/azure/bundles/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/bundles/test_wasb.py 
b/providers/microsoft/azure/tests/unit/microsoft/azure/bundles/test_wasb.py
new file mode 100644
index 00000000000..1c450d2326e
--- /dev/null
+++ b/providers/microsoft/azure/tests/unit/microsoft/azure/bundles/test_wasb.py
@@ -0,0 +1,279 @@
+# 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
+
+from unittest.mock import MagicMock, PropertyMock, call, patch
+
+import pytest
+
+import airflow.version
+from airflow.models import Connection
+
+from tests_common.test_utils.config import conf_vars
+
+if airflow.version.version.strip().startswith("3"):
+    from airflow.providers.microsoft.azure.bundles.wasb import WasbDagBundle
+
+WASB_CONN_ID = "wasb_dags_connection"
+CONTAINER_NAME = "my-airflow-dags-container"
+CONTAINER_PREFIX = "project1/dags"
+ACCOUNT_NAME = "myaccount"
+ACCOUNT_URL = f"https://{ACCOUNT_NAME}.blob.core.windows.net";
+
+
[email protected](autouse=True)
+def bundle_temp_dir(tmp_path):
+    with conf_vars({("dag_processor", "dag_bundle_storage_path"): 
str(tmp_path)}):
+        yield tmp_path
+
+
[email protected](not airflow.version.version.strip().startswith("3"), 
reason="Airflow >=3.0.0 test")
+class TestWasbDagBundle:
+    @pytest.fixture(autouse=True)
+    def setup_connections(self, create_connection_without_db):
+        create_connection_without_db(
+            Connection(
+                conn_id=WASB_CONN_ID,
+                conn_type="wasb",
+                login=ACCOUNT_NAME,
+                password="password",
+            )
+        )
+
+    @patch(
+        
"airflow.providers.microsoft.azure.bundles.wasb.WasbDagBundle.wasb_hook", 
new_callable=PropertyMock
+    )
+    def test_view_url_generates_blob_url(self, mock_wasb_hook_property):
+        mock_hook = MagicMock()
+        mock_hook.blob_service_client.url = ACCOUNT_URL
+        mock_wasb_hook_property.return_value = mock_hook
+
+        bundle = WasbDagBundle(
+            name="test",
+            wasb_conn_id=WASB_CONN_ID,
+            prefix=CONTAINER_PREFIX,
+            container_name=CONTAINER_NAME,
+        )
+        url: str = bundle.view_url()
+        assert url == f"{ACCOUNT_URL}/{CONTAINER_NAME}/{CONTAINER_PREFIX}"
+
+    @patch(
+        
"airflow.providers.microsoft.azure.bundles.wasb.WasbDagBundle.wasb_hook", 
new_callable=PropertyMock
+    )
+    def test_view_url_template_generates_blob_url(self, 
mock_wasb_hook_property):
+        mock_hook = MagicMock()
+        mock_hook.blob_service_client.url = ACCOUNT_URL
+        mock_wasb_hook_property.return_value = mock_hook
+
+        bundle = WasbDagBundle(
+            name="test",
+            wasb_conn_id=WASB_CONN_ID,
+            prefix=CONTAINER_PREFIX,
+            container_name=CONTAINER_NAME,
+        )
+        url: str = bundle.view_url_template()
+        assert url == f"{ACCOUNT_URL}/{CONTAINER_NAME}/{CONTAINER_PREFIX}"
+
+    @patch(
+        
"airflow.providers.microsoft.azure.bundles.wasb.WasbDagBundle.wasb_hook", 
new_callable=PropertyMock
+    )
+    def test_view_url_template_strips_sas_token(self, mock_wasb_hook_property):
+        mock_hook = MagicMock()
+        mock_hook.blob_service_client.url = 
f"{ACCOUNT_URL}/?sv=2021-08-06&sp=r&sig=samplesignature"
+        mock_wasb_hook_property.return_value = mock_hook
+
+        bundle = WasbDagBundle(
+            name="test",
+            wasb_conn_id=WASB_CONN_ID,
+            prefix=CONTAINER_PREFIX,
+            container_name=CONTAINER_NAME,
+        )
+        assert bundle.view_url_template() == 
f"{ACCOUNT_URL}/{CONTAINER_NAME}/{CONTAINER_PREFIX}"
+
+    def test_supports_versioning(self):
+        bundle = WasbDagBundle(
+            name="test",
+            wasb_conn_id=WASB_CONN_ID,
+            prefix=CONTAINER_PREFIX,
+            container_name=CONTAINER_NAME,
+        )
+        assert WasbDagBundle.supports_versioning is False
+
+        bundle.version = "test_version"
+
+        with pytest.raises(ValueError, match="Refreshing a specific version is 
not supported"):
+            bundle.refresh()
+        with pytest.raises(ValueError, match="WASB url with version is not 
supported"):
+            bundle.view_url("test_version")
+
+    def test_local_dags_path_is_not_a_directory(self, bundle_temp_dir):
+        bundle_name = "test"
+        file_path = bundle_temp_dir / bundle_name
+        file_path.touch()
+
+        bundle = WasbDagBundle(
+            name=bundle_name,
+            wasb_conn_id=WASB_CONN_ID,
+            prefix="project1_dags",
+            container_name="airflow_dags",
+        )
+        with pytest.raises(NotADirectoryError, match=f"Local Dags path: 
{file_path} is not a directory."):
+            bundle.initialize()
+
+    def test_correct_bundle_path_used(self):
+        bundle = WasbDagBundle(
+            name="test",
+            wasb_conn_id=WASB_CONN_ID,
+            prefix="project1_dags",
+            container_name="airflow_dags",
+        )
+        assert str(bundle.base_dir) == str(bundle.wasb_dags_dir)
+
+    @patch(
+        
"airflow.providers.microsoft.azure.bundles.wasb.WasbDagBundle.wasb_hook", 
new_callable=PropertyMock
+    )
+    def test_wasb_container_and_prefix_validated(self, 
mock_wasb_hook_property):
+        mock_hook = MagicMock()
+        mock_wasb_hook_property.return_value = mock_hook
+
+        mock_hook.check_for_container.return_value = False
+        bundle = WasbDagBundle(
+            name="test",
+            wasb_conn_id=WASB_CONN_ID,
+            prefix="project1_dags",
+            container_name="non-existing-container",
+        )
+        with pytest.raises(ValueError, match="WASB container 
'non-existing-container' does not exist."):
+            bundle.initialize()
+        
mock_hook.check_for_container.assert_called_once_with(container_name="non-existing-container")
+
+        mock_hook.check_for_container.return_value = True
+        mock_hook.check_for_prefix.return_value = False
+        bundle = WasbDagBundle(
+            name="test",
+            wasb_conn_id=WASB_CONN_ID,
+            prefix="non-existing-prefix",
+            container_name=CONTAINER_NAME,
+        )
+        with pytest.raises(
+            ValueError,
+            match=f"WASB prefix 'wasb://{CONTAINER_NAME}/non-existing-prefix' 
does not exist.",
+        ):
+            bundle.initialize()
+        mock_hook.check_for_prefix.assert_called_once_with(
+            container_name=CONTAINER_NAME, prefix="non-existing-prefix/", 
delimiter="/"
+        )
+
+        mock_hook.check_for_prefix.return_value = True
+        bundle = WasbDagBundle(
+            name="test",
+            wasb_conn_id=WASB_CONN_ID,
+            prefix=CONTAINER_PREFIX,
+            container_name=CONTAINER_NAME,
+        )
+        bundle.initialize()
+
+        mock_hook.check_for_prefix.reset_mock()
+        bundle = WasbDagBundle(
+            name="test",
+            wasb_conn_id=WASB_CONN_ID,
+            prefix="",
+            container_name=CONTAINER_NAME,
+        )
+        bundle.initialize()
+        mock_hook.check_for_prefix.assert_not_called()
+
+    @patch(
+        
"airflow.providers.microsoft.azure.bundles.wasb.WasbDagBundle.wasb_hook", 
new_callable=PropertyMock
+    )
+    def test_refresh(self, mock_wasb_hook_property):
+        mock_hook = MagicMock()
+        mock_hook.check_for_container.return_value = True
+        mock_hook.check_for_prefix.return_value = True
+        mock_wasb_hook_property.return_value = mock_hook
+
+        bundle = WasbDagBundle(
+            name="test",
+            wasb_conn_id=WASB_CONN_ID,
+            prefix=CONTAINER_PREFIX,
+            container_name=CONTAINER_NAME,
+        )
+        bundle._log.debug = MagicMock()
+        download_log_call = call(
+            "Downloading Dags from wasb://%s/%s to %s",
+            CONTAINER_NAME,
+            CONTAINER_PREFIX,
+            bundle.wasb_dags_dir,
+        )
+        sync_call = call(
+            container_name=CONTAINER_NAME,
+            prefix=CONTAINER_PREFIX,
+            local_dir=bundle.wasb_dags_dir,
+            delete_stale=True,
+        )
+
+        bundle.initialize()
+        assert bundle._log.debug.call_count == 1
+        assert bundle._log.debug.call_args_list == [download_log_call]
+        assert mock_hook.sync_to_local_dir.call_count == 1
+        assert mock_hook.sync_to_local_dir.call_args_list == [sync_call]
+
+        bundle.refresh()
+        assert bundle._log.debug.call_count == 2
+        assert bundle._log.debug.call_args_list == [download_log_call, 
download_log_call]
+        assert mock_hook.sync_to_local_dir.call_count == 2
+        assert mock_hook.sync_to_local_dir.call_args_list == [sync_call, 
sync_call]
+
+    @patch(
+        
"airflow.providers.microsoft.azure.bundles.wasb.WasbDagBundle.wasb_hook", 
new_callable=PropertyMock
+    )
+    def test_refresh_without_prefix(self, mock_wasb_hook_property):
+        mock_hook = MagicMock()
+        mock_hook.check_for_container.return_value = True
+        mock_wasb_hook_property.return_value = mock_hook
+
+        bundle = WasbDagBundle(
+            name="test",
+            wasb_conn_id=WASB_CONN_ID,
+            container_name=CONTAINER_NAME,
+        )
+        bundle._log.debug = MagicMock()
+        download_log_call = call(
+            "Downloading Dags from wasb://%s/%s to %s",
+            CONTAINER_NAME,
+            "",
+            bundle.wasb_dags_dir,
+        )
+        sync_call = call(
+            container_name=CONTAINER_NAME,
+            prefix="",
+            local_dir=bundle.wasb_dags_dir,
+            delete_stale=True,
+        )
+
+        assert bundle.prefix == ""
+        bundle.initialize()
+        assert bundle._log.debug.call_count == 1
+        assert bundle._log.debug.call_args_list == [download_log_call]
+        assert mock_hook.sync_to_local_dir.call_count == 1
+        assert mock_hook.sync_to_local_dir.call_args_list == [sync_call]
+
+        bundle.refresh()
+        assert bundle._log.debug.call_count == 2
+        assert bundle._log.debug.call_args_list == [download_log_call, 
download_log_call]
+        assert mock_hook.sync_to_local_dir.call_count == 2
+        assert mock_hook.sync_to_local_dir.call_args_list == [sync_call, 
sync_call]
diff --git 
a/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_wasb.py 
b/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_wasb.py
index 45670510944..5166429288c 100644
--- a/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_wasb.py
+++ b/providers/microsoft/azure/tests/unit/microsoft/azure/hooks/test_wasb.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 
 import os
 import re
+from datetime import UTC, datetime
 from unittest import mock
 from unittest.mock import create_autospec
 
@@ -457,6 +458,37 @@ class TestWasbHook:
         assert not hook.check_for_prefix("container", "prefix", timeout=3)
         get_blobs_list.assert_called_once_with(container_name="container", 
prefix="prefix", timeout=3)
 
+    def test_check_for_container(self, mocked_blob_service_client):
+        mock_container = create_autospec(ContainerClient, instance=True)
+        mock_container.exists.return_value = True
+        
mocked_blob_service_client.return_value.get_container_client.return_value = 
mock_container
+        hook = WasbHook(wasb_conn_id=self.azure_shared_key_test)
+        assert hook.check_for_container("mycontainer") is True
+        
mocked_blob_service_client.return_value.get_container_client.assert_called_once_with("mycontainer")
+        mock_container.exists.assert_called_once_with()
+
+    def test_check_for_container_not_found(self, mocked_blob_service_client):
+        mock_container = create_autospec(ContainerClient, instance=True)
+        mock_container.exists.return_value = False
+        
mocked_blob_service_client.return_value.get_container_client.return_value = 
mock_container
+        hook = WasbHook(wasb_conn_id=self.azure_shared_key_test)
+        assert hook.check_for_container("missing-container") is False
+        mock_container.exists.assert_called_once_with()
+
+    def test_check_for_container_raises_type_error_for_invalid_client(self, 
mocked_blob_service_client):
+        
mocked_blob_service_client.return_value.get_container_client.return_value = 
mock.MagicMock()
+        hook = WasbHook(wasb_conn_id=self.azure_shared_key_test)
+        with pytest.raises(TypeError, match="container for WasbHook must be 
ContainerClient"):
+            hook.check_for_container("mycontainer")
+
+    def test_check_for_container_propagates_unexpected_errors(self, 
mocked_blob_service_client):
+        mock_container = create_autospec(ContainerClient, instance=True)
+        mock_container.exists.side_effect = OSError("network down")
+        
mocked_blob_service_client.return_value.get_container_client.return_value = 
mock_container
+        hook = WasbHook(wasb_conn_id=self.azure_shared_key_test)
+        with pytest.raises(OSError, match="network down"):
+            hook.check_for_container("mycontainer")
+
     def test_get_blobs_list(self, mocked_blob_service_client):
         mock_container = create_autospec(ContainerClient, instance=True)
         
mocked_blob_service_client.return_value.get_container_client.return_value = 
mock_container
@@ -565,6 +597,134 @@ class TestWasbHook:
         blob_client.assert_called_once_with(container="mycontainer", 
blob="myblob")
         
blob_client.return_value.download_blob.assert_called_once_with(offset=2, 
length=4)
 
+    def test_sync_to_local_dir_behaviour(self, mocked_blob_service_client, 
tmp_path):
+        def get_logs_string(call_args_list):
+            return "".join([args[0][0] % args[0][1:] for args in 
call_args_list])
+
+        def make_blob(name, size=9, last_modified=None):
+            blob = mock.MagicMock(spec=BlobProperties)
+            blob.name = name
+            blob.size = size
+            blob.last_modified = last_modified or datetime(2026, 1, 1, 12, 0, 
0, tzinfo=UTC)
+            return blob
+
+        container_name = "test_container"
+        mock_container = create_autospec(ContainerClient, instance=True)
+        
mocked_blob_service_client.return_value.get_container_client.return_value = 
mock_container
+
+        blobs = [
+            make_blob("dag_01.py"),
+            make_blob("dag_02.py"),
+            make_blob("subproject1/dag_a.py"),
+            make_blob("subproject1/dag_b.py"),
+        ]
+        mock_container.list_blobs.return_value = blobs
+
+        sync_local_dir = tmp_path / "wasb_sync_dir"
+        hook = WasbHook(wasb_conn_id=self.azure_shared_key_test)
+        hook.log.debug = mock.MagicMock()
+        hook.get_file = mock.MagicMock()
+
+        hook.sync_to_local_dir(
+            container_name=container_name, local_dir=sync_local_dir, 
prefix="", delete_stale=True
+        )
+        logs_string = get_logs_string(hook.log.debug.call_args_list)
+        assert f"Downloading data from wasb://{container_name}/ to 
{sync_local_dir}" in logs_string
+        assert f"Local file {sync_local_dir}/dag_01.py does not exist." in 
logs_string
+        assert f"Downloaded dag_01.py to 
{sync_local_dir.as_posix()}/dag_01.py" in logs_string
+        assert f"Local file {sync_local_dir}/subproject1/dag_a.py does not 
exist." in logs_string
+        assert (
+            f"Downloaded subproject1/dag_a.py to 
{sync_local_dir.as_posix()}/subproject1/dag_a.py"
+            in logs_string
+        )
+        assert hook.get_file.call_count == 4
+
+        for blob in blobs:
+            p = sync_local_dir / blob.name
+            p.parent.mkdir(parents=True, exist_ok=True)
+            p.write_text("test data")
+            os.utime(p, (blob.last_modified.timestamp(), 
blob.last_modified.timestamp()))
+
+        hook.log.debug = mock.MagicMock()
+        hook.get_file.reset_mock()
+        new_blob = make_blob("dag_03.py")
+        mock_container.list_blobs.return_value = blobs + [new_blob]
+        hook.sync_to_local_dir(
+            container_name=container_name, local_dir=sync_local_dir, 
prefix="", delete_stale=True
+        )
+        logs_string = get_logs_string(hook.log.debug.call_args_list)
+        assert (
+            f"Local file {(sync_local_dir / 'subproject1' / 
'dag_b.py').as_posix()} is up-to-date "
+            "with blob subproject1/dag_b.py. Skipping download."
+        ) in logs_string
+        assert f"Local file {sync_local_dir}/dag_03.py does not exist." in 
logs_string
+        assert f"Downloaded dag_03.py to 
{sync_local_dir.as_posix()}/dag_03.py" in logs_string
+        hook.get_file.assert_called_once()
+        (sync_local_dir / "dag_03.py").write_text("test data")
+        os.utime(
+            sync_local_dir / "dag_03.py",
+            (new_blob.last_modified.timestamp(), 
new_blob.last_modified.timestamp()),
+        )
+
+        local_file_that_should_be_deleted = sync_local_dir / 
"file_that_should_be_deleted.py"
+        local_file_that_should_be_deleted.write_text("test dag")
+        local_folder_should_be_deleted = sync_local_dir / 
"local_folder_should_be_deleted"
+        local_folder_should_be_deleted.mkdir(exist_ok=True)
+        hook.log.debug = mock.MagicMock()
+        hook.get_file.reset_mock()
+        hook.sync_to_local_dir(
+            container_name=container_name, local_dir=sync_local_dir, 
prefix="", delete_stale=True
+        )
+        logs_string = get_logs_string(hook.log.debug.call_args_list)
+        assert f"Deleting stale local file: 
{local_file_that_should_be_deleted.as_posix()}" in logs_string
+        assert f"Deleting stale empty directory: 
{local_folder_should_be_deleted.as_posix()}" in logs_string
+        assert not hook.get_file.called
+
+        hook.log.debug = mock.MagicMock()
+        hook.get_file.reset_mock()
+        updated_blob = make_blob("dag_03.py", size=15)
+        mock_container.list_blobs.return_value = blobs + [updated_blob]
+        hook.sync_to_local_dir(
+            container_name=container_name, local_dir=sync_local_dir, 
prefix="", delete_stale=True
+        )
+        logs_string = get_logs_string(hook.log.debug.call_args_list)
+        assert "Blob size (15) and local file size (9) differ." in logs_string
+        assert f"Downloaded dag_03.py to 
{sync_local_dir.as_posix()}/dag_03.py" in logs_string
+        hook.get_file.assert_called_once()
+
+    @pytest.mark.parametrize("prefix", ["project1/dags", "project1/dags/"])
+    def test_sync_to_local_dir_with_prefix(self, mocked_blob_service_client, 
tmp_path, prefix):
+        mock_container = create_autospec(ContainerClient, instance=True)
+        
mocked_blob_service_client.return_value.get_container_client.return_value = 
mock_container
+        blob = mock.MagicMock(spec=BlobProperties)
+        blob.name = "project1/dags/dag_a.py"
+        mock_container.list_blobs.return_value = [blob]
+        hook = WasbHook(wasb_conn_id=self.azure_shared_key_test)
+        hook.get_file = mock.MagicMock()
+
+        hook.sync_to_local_dir(container_name="test_container", 
local_dir=tmp_path, prefix=prefix)
+
+        
mock_container.list_blobs.assert_called_once_with(name_starts_with="project1/dags/")
+        hook.get_file.assert_called_once_with(
+            file_path=str(tmp_path / "dag_a.py"), 
container_name="test_container", blob_name=blob.name
+        )
+
+    def test_sync_to_local_dir_rejects_blob_path_traversal(self, 
mocked_blob_service_client, tmp_path):
+        mock_container = create_autospec(ContainerClient, instance=True)
+        
mocked_blob_service_client.return_value.get_container_client.return_value = 
mock_container
+        blob = mock.MagicMock(spec=BlobProperties)
+        blob.name = "dags/../../outside.py"
+        mock_container.list_blobs.return_value = [blob]
+        hook = WasbHook(wasb_conn_id=self.azure_shared_key_test)
+        hook.get_file = mock.MagicMock()
+
+        with pytest.raises(ValueError, match="resolves outside local 
directory"):
+            hook.sync_to_local_dir(
+                container_name="test_container", local_dir=tmp_path / 
"bundle", prefix="dags"
+            )
+        assert not (tmp_path / "outside.py").exists()
+        hook.get_file.assert_not_called()
+
     def test_get_container_client(self, mocked_blob_service_client):
         hook = WasbHook(wasb_conn_id=self.azure_shared_key_test)
         hook._get_container_client("mycontainer")

Reply via email to