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

eladkal 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 263fafcec05 Add Databricks SQL warehouse lifecycle operators (#70088)
263fafcec05 is described below

commit 263fafcec054c5463a76225937dd8e1b528a691c
Author: deepinsight coder <[email protected]>
AuthorDate: Mon Aug 17 10:38:01 2026 -0700

    Add Databricks SQL warehouse lifecycle operators (#70088)
    
    * Add Databricks SQL warehouse lifecycle operators
    
    * Honor observed Databricks warehouse states at timeout
    
    A slow final status request can finish after the deadline even when it 
confirms the requested state. Treating that observation as a timeout can fail 
an otherwise successful Dag.
    
    * Avoid false failures when starting Databricks warehouses
    
    A warehouse can still report STOPPED immediately after the start request 
because the API response is eventually consistent. Keep polling until RUNNING 
or deletion/timeout so valid starts do not fail spuriously.
    
    * Address review feedback on Databricks SQL warehouse operators
    
    Rename the operator module to warehouse.py to follow provider naming
    conventions, drop the unused WarehouseState.to_json/from_json helpers
    that have no production caller until the deferrable trigger lands, and
    describe the all_done trigger rule behavior in the docs without
    referring to system tests.
    
    Co-Authored-By: Claude Fable 5 <[email protected]>
    
    * Rename Databricks SQL warehouse how-to page to warehouse.rst
    
    Match the operator module name so the how-to page follows the
    same provider naming convention as warehouse.py.
    
    Co-authored-by: nrvamsi13 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Fable 5 <[email protected]>
    Co-authored-by: Cursor Agent <[email protected]>
---
 providers/databricks/docs/operators/warehouse.rst  |  55 +++
 providers/databricks/provider.yaml                 |   2 +
 .../src/airflow/providers/databricks/exceptions.py |   4 +
 .../providers/databricks/get_provider_info.py      |   6 +-
 .../providers/databricks/hooks/databricks.py       |  73 ++++
 .../providers/databricks/operators/warehouse.py    | 156 ++++++++
 .../databricks/example_databricks_sql_warehouse.py |  67 ++++
 .../tests/unit/databricks/hooks/test_databricks.py |  63 ++++
 .../unit/databricks/operators/test_warehouse.py    | 406 +++++++++++++++++++++
 9 files changed, 831 insertions(+), 1 deletion(-)

diff --git a/providers/databricks/docs/operators/warehouse.rst 
b/providers/databricks/docs/operators/warehouse.rst
new file mode 100644
index 00000000000..1467bbb7f45
--- /dev/null
+++ b/providers/databricks/docs/operators/warehouse.rst
@@ -0,0 +1,55 @@
+ .. 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.
+
+.. _howto/operator:DatabricksStartWarehouseOperator:
+.. _howto/operator:DatabricksStopWarehouseOperator:
+
+Databricks SQL warehouse lifecycle operators
+============================================
+
+Use 
:class:`~airflow.providers.databricks.operators.warehouse.DatabricksStartWarehouseOperator`
+and 
:class:`~airflow.providers.databricks.operators.warehouse.DatabricksStopWarehouseOperator`
+to start and stop an existing Databricks SQL warehouse through the
+`Databricks SQL Warehouses API 
<https://docs.databricks.com/api/workspace/warehouses>`_.
+Both operators require the warehouse ID and use the :ref:`Databricks connection
+<howto/connection:databricks>` for authentication.
+
+By default, each operator waits for the requested state: ``RUNNING`` when 
starting and ``STOPPED``
+when stopping. Use ``polling_period_seconds`` to control the polling interval, 
``timeout`` to limit
+the wait, or ``wait_for_termination=False`` to return after requesting the 
transition. Repeated task
+attempts are safe: an already running warehouse is not started again, and an 
already stopped warehouse
+is not stopped again. If a start is requested while a warehouse is stopping, 
any transition rejection
+from Databricks is propagated to the task.
+
+Start a SQL warehouse
+---------------------
+
+.. exampleinclude:: 
/../../databricks/tests/system/databricks/example_databricks_sql_warehouse.py
+    :language: python
+    :start-after: [START howto_operator_databricks_start_sql_warehouse]
+    :end-before: [END howto_operator_databricks_start_sql_warehouse]
+
+Stop a SQL warehouse
+--------------------
+
+The stop task uses the ``all_done`` trigger rule so the warehouse is stopped 
even when an upstream
+task fails.
+
+.. exampleinclude:: 
/../../databricks/tests/system/databricks/example_databricks_sql_warehouse.py
+    :language: python
+    :start-after: [START howto_operator_databricks_stop_sql_warehouse]
+    :end-before: [END howto_operator_databricks_stop_sql_warehouse]
diff --git a/providers/databricks/provider.yaml 
b/providers/databricks/provider.yaml
index f2dc2d7a2de..14e46e4fe1f 100644
--- a/providers/databricks/provider.yaml
+++ b/providers/databricks/provider.yaml
@@ -125,6 +125,7 @@ integrations:
     how-to-guide:
       - /docs/apache-airflow-providers-databricks/operators/sql.rst
       - /docs/apache-airflow-providers-databricks/operators/sql_statements.rst
+      - /docs/apache-airflow-providers-databricks/operators/warehouse.rst
       - /docs/apache-airflow-providers-databricks/operators/copy_into.rst
     tags: [service]
   - integration-name: Databricks Repos
@@ -147,6 +148,7 @@ operators:
   - integration-name: Databricks SQL
     python-modules:
       - airflow.providers.databricks.operators.databricks_sql
+      - airflow.providers.databricks.operators.warehouse
   - integration-name: Databricks Repos
     python-modules:
       - airflow.providers.databricks.operators.databricks_repos
diff --git 
a/providers/databricks/src/airflow/providers/databricks/exceptions.py 
b/providers/databricks/src/airflow/providers/databricks/exceptions.py
index 0831810f70f..a0f52a6077c 100644
--- a/providers/databricks/src/airflow/providers/databricks/exceptions.py
+++ b/providers/databricks/src/airflow/providers/databricks/exceptions.py
@@ -36,6 +36,10 @@ class DatabricksOperatorPayloadError(AirflowException):
     """Raised when a Databricks operator payload is invalid."""
 
 
+class DatabricksWarehouseError(AirflowException):
+    """Raised when a SQL warehouse fails to reach or times out waiting for a 
target state."""
+
+
 class DatabricksApiError(AirflowException):
     """Raised when a Databricks REST API call returns an error response."""
 
diff --git 
a/providers/databricks/src/airflow/providers/databricks/get_provider_info.py 
b/providers/databricks/src/airflow/providers/databricks/get_provider_info.py
index f80d54a130d..a218ce35464 100644
--- a/providers/databricks/src/airflow/providers/databricks/get_provider_info.py
+++ b/providers/databricks/src/airflow/providers/databricks/get_provider_info.py
@@ -46,6 +46,7 @@ def get_provider_info():
                 "how-to-guide": [
                     
"/docs/apache-airflow-providers-databricks/operators/sql.rst",
                     
"/docs/apache-airflow-providers-databricks/operators/sql_statements.rst",
+                    
"/docs/apache-airflow-providers-databricks/operators/warehouse.rst",
                     
"/docs/apache-airflow-providers-databricks/operators/copy_into.rst",
                 ],
                 "tags": ["service"],
@@ -74,7 +75,10 @@ def get_provider_info():
             },
             {
                 "integration-name": "Databricks SQL",
-                "python-modules": 
["airflow.providers.databricks.operators.databricks_sql"],
+                "python-modules": [
+                    "airflow.providers.databricks.operators.databricks_sql",
+                    "airflow.providers.databricks.operators.warehouse",
+                ],
             },
             {
                 "integration-name": "Databricks Repos",
diff --git 
a/providers/databricks/src/airflow/providers/databricks/hooks/databricks.py 
b/providers/databricks/src/airflow/providers/databricks/hooks/databricks.py
index d780816f9fa..4a0f6454983 100644
--- a/providers/databricks/src/airflow/providers/databricks/hooks/databricks.py
+++ b/providers/databricks/src/airflow/providers/databricks/hooks/databricks.py
@@ -69,6 +69,7 @@ WORKSPACE_GET_STATUS_ENDPOINT = ("GET", 
"2.0/workspace/get-status")
 
 SPARK_VERSIONS_ENDPOINT = ("GET", "2.1/clusters/spark-versions")
 SQL_STATEMENTS_ENDPOINT = "2.0/sql/statements"
+SQL_WAREHOUSES_ENDPOINT = "2.0/sql/warehouses"
 
 
 class RunLifeCycleState(Enum):
@@ -267,6 +268,46 @@ class SQLStatementState:
         return SQLStatementState(**json.loads(data))
 
 
+class WarehouseState:
+    """Utility class for the state of a Databricks SQL warehouse."""
+
+    WAREHOUSE_STATES = ["STARTING", "RUNNING", "STOPPING", "STOPPED", 
"DELETING", "DELETED"]
+
+    def __init__(self, state: str = "", *args, **kwargs) -> None:
+        if state not in self.WAREHOUSE_STATES:
+            raise ValueError(
+                f"Unexpected warehouse state: {state}: If the state has been 
introduced recently, "
+                "please check the Databricks user guide for troubleshooting 
information"
+            )
+        self.state = state
+
+    @property
+    def is_running(self) -> bool:
+        """Return whether the warehouse is running."""
+        return self.state == "RUNNING"
+
+    @property
+    def is_stopped(self) -> bool:
+        """Return whether the warehouse is stopped."""
+        return self.state == "STOPPED"
+
+    @property
+    def is_deleted(self) -> bool:
+        """Return whether the warehouse is deleting or deleted."""
+        return self.state in ("DELETING", "DELETED")
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, WarehouseState):
+            return NotImplemented
+        return self.state == other.state
+
+    def __hash__(self):
+        return hash(self.state)
+
+    def __repr__(self) -> str:
+        return str(self.__dict__)
+
+
 class DatabricksHook(BaseDatabricksHook):
     """
     Interact with Databricks.
@@ -742,6 +783,38 @@ class DatabricksHook(BaseDatabricksHook):
         """
         self._do_api_call(TERMINATE_CLUSTER_ENDPOINT, json)
 
+    def get_warehouse(self, warehouse_id: str) -> dict[str, Any]:
+        """
+        Retrieve a Databricks SQL warehouse.
+
+        :param warehouse_id: ID of the SQL warehouse.
+        """
+        return self._do_api_call(("GET", 
f"{SQL_WAREHOUSES_ENDPOINT}/{warehouse_id}"))
+
+    def get_warehouse_state(self, warehouse_id: str) -> WarehouseState:
+        """
+        Retrieve the state of a Databricks SQL warehouse.
+
+        :param warehouse_id: ID of the SQL warehouse.
+        """
+        return WarehouseState(self.get_warehouse(warehouse_id)["state"])
+
+    def start_warehouse(self, warehouse_id: str) -> None:
+        """
+        Start a Databricks SQL warehouse.
+
+        :param warehouse_id: ID of the SQL warehouse.
+        """
+        self._do_api_call(("POST", 
f"{SQL_WAREHOUSES_ENDPOINT}/{warehouse_id}/start"))
+
+    def stop_warehouse(self, warehouse_id: str) -> None:
+        """
+        Stop a Databricks SQL warehouse.
+
+        :param warehouse_id: ID of the SQL warehouse.
+        """
+        self._do_api_call(("POST", 
f"{SQL_WAREHOUSES_ENDPOINT}/{warehouse_id}/stop"))
+
     def install(self, json: dict) -> None:
         """
         Install libraries on the cluster.
diff --git 
a/providers/databricks/src/airflow/providers/databricks/operators/warehouse.py 
b/providers/databricks/src/airflow/providers/databricks/operators/warehouse.py
new file mode 100644
index 00000000000..e2d0ce9a69b
--- /dev/null
+++ 
b/providers/databricks/src/airflow/providers/databricks/operators/warehouse.py
@@ -0,0 +1,156 @@
+# 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.
+"""Operators for managing Databricks SQL warehouse lifecycle state."""
+
+from __future__ import annotations
+
+import time
+from collections.abc import Sequence
+from functools import cached_property
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.common.compat.sdk import BaseOperator
+from airflow.providers.databricks.exceptions import DatabricksWarehouseError
+from airflow.providers.databricks.hooks.databricks import DatabricksHook
+
+if TYPE_CHECKING:
+    from airflow.providers.common.compat.sdk import Context
+
+
+class _DatabricksWarehouseBaseOperator(BaseOperator):
+    """Share Databricks SQL warehouse connection and polling behavior."""
+
+    template_fields: Sequence[str] = ("databricks_conn_id", "warehouse_id")
+    ui_color = "#1CB1C2"
+    ui_fgcolor = "#fff"
+
+    def __init__(
+        self,
+        warehouse_id: str,
+        *,
+        databricks_conn_id: str = "databricks_default",
+        wait_for_termination: bool = True,
+        polling_period_seconds: int = 30,
+        timeout: float = 3600,
+        databricks_retry_limit: int = 3,
+        databricks_retry_delay: int = 1,
+        databricks_retry_args: dict[Any, Any] | None = None,
+        **kwargs,
+    ) -> None:
+        super().__init__(**kwargs)
+        self.warehouse_id = warehouse_id
+        self.databricks_conn_id = databricks_conn_id
+        self.wait_for_termination = wait_for_termination
+        self.polling_period_seconds = polling_period_seconds
+        self.timeout = timeout
+        self.databricks_retry_limit = databricks_retry_limit
+        self.databricks_retry_delay = databricks_retry_delay
+        self.databricks_retry_args = databricks_retry_args
+
+    def _validate_warehouse_id(self) -> None:
+        if not self.warehouse_id:
+            raise ValueError("warehouse_id must be provided.")
+
+    @cached_property
+    def _hook(self) -> DatabricksHook:
+        return DatabricksHook(
+            self.databricks_conn_id,
+            retry_limit=self.databricks_retry_limit,
+            retry_delay=self.databricks_retry_delay,
+            retry_args=self.databricks_retry_args,
+            caller=self.__class__.__name__,
+        )
+
+    def _wait_for_state(self, target: str) -> None:
+        deadline = time.monotonic() + self.timeout
+        last_state = "unknown"
+        while time.monotonic() < deadline:
+            state = self._hook.get_warehouse_state(self.warehouse_id)
+            last_state = state.state
+            now = time.monotonic()
+            if state.state == target:
+                return
+            if state.is_deleted:
+                raise DatabricksWarehouseError(
+                    f"Databricks SQL warehouse {self.warehouse_id} entered 
{state.state} "
+                    f"while waiting for {target}."
+                )
+            if now >= deadline:
+                break
+            self.log.info(
+                "Databricks SQL warehouse %s is %s; waiting for %s.",
+                self.warehouse_id,
+                state.state,
+                target,
+            )
+            time.sleep(min(self.polling_period_seconds, deadline - now))
+        raise DatabricksWarehouseError(
+            f"Databricks SQL warehouse {self.warehouse_id} did not reach 
{target} "
+            f"within {self.timeout}s; last state: {last_state}."
+        )
+
+
+class DatabricksStartWarehouseOperator(_DatabricksWarehouseBaseOperator):
+    """
+    Start a Databricks SQL warehouse and optionally wait for it to run.
+
+    :param warehouse_id: ID of the Databricks SQL warehouse. (templated)
+    :param databricks_conn_id: Reference to the Databricks connection. 
(templated)
+    :param wait_for_termination: Wait until the warehouse reaches ``RUNNING``.
+    :param polling_period_seconds: Number of seconds between state checks.
+    :param timeout: Maximum number of seconds to wait for the target state.
+    :param databricks_retry_limit: Number of times to retry unavailable 
Databricks requests.
+    :param databricks_retry_delay: Number of seconds between Databricks 
request retries.
+    :param databricks_retry_args: Additional arguments for 
``tenacity.Retrying``.
+    """
+
+    def execute(self, context: Context) -> None:
+        self._validate_warehouse_id()
+        state = self._hook.get_warehouse_state(self.warehouse_id)
+        if state.is_running:
+            self.log.info("Databricks SQL warehouse %s is already running.", 
self.warehouse_id)
+            return
+        if state.state != "STARTING":
+            self._hook.start_warehouse(self.warehouse_id)
+        if self.wait_for_termination:
+            self._wait_for_state("RUNNING")
+
+
+class DatabricksStopWarehouseOperator(_DatabricksWarehouseBaseOperator):
+    """
+    Stop a Databricks SQL warehouse and optionally wait for it to stop.
+
+    :param warehouse_id: ID of the Databricks SQL warehouse. (templated)
+    :param databricks_conn_id: Reference to the Databricks connection. 
(templated)
+    :param wait_for_termination: Wait until the warehouse reaches ``STOPPED``.
+    :param polling_period_seconds: Number of seconds between state checks.
+    :param timeout: Maximum number of seconds to wait for the target state.
+    :param databricks_retry_limit: Number of times to retry unavailable 
Databricks requests.
+    :param databricks_retry_delay: Number of seconds between Databricks 
request retries.
+    :param databricks_retry_args: Additional arguments for 
``tenacity.Retrying``.
+    """
+
+    def execute(self, context: Context) -> None:
+        self._validate_warehouse_id()
+        state = self._hook.get_warehouse_state(self.warehouse_id)
+        if state.is_stopped:
+            self.log.info("Databricks SQL warehouse %s is already stopped.", 
self.warehouse_id)
+            return
+        if state.state != "STOPPING":
+            self._hook.stop_warehouse(self.warehouse_id)
+        if self.wait_for_termination:
+            self._wait_for_state("STOPPED")
diff --git 
a/providers/databricks/tests/system/databricks/example_databricks_sql_warehouse.py
 
b/providers/databricks/tests/system/databricks/example_databricks_sql_warehouse.py
new file mode 100644
index 00000000000..81f2f5591b5
--- /dev/null
+++ 
b/providers/databricks/tests/system/databricks/example_databricks_sql_warehouse.py
@@ -0,0 +1,67 @@
+# 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.
+"""Example Dag for starting and stopping an existing Databricks SQL 
warehouse."""
+
+from __future__ import annotations
+
+import os
+from datetime import datetime
+
+from airflow.providers.common.compat.sdk import DAG
+from airflow.providers.databricks.operators.warehouse import (
+    DatabricksStartWarehouseOperator,
+    DatabricksStopWarehouseOperator,
+)
+
+DAG_ID = "example_databricks_sql_warehouse"
+WAREHOUSE_ID = os.environ.get("WAREHOUSE_ID", "your-warehouse-id")
+
+with DAG(
+    dag_id=DAG_ID,
+    schedule="@daily",
+    start_date=datetime(2021, 1, 1),
+    tags=["example"],
+    catchup=False,
+) as dag:
+    # [START howto_operator_databricks_start_sql_warehouse]
+    start_warehouse = DatabricksStartWarehouseOperator(
+        task_id="start_warehouse",
+        databricks_conn_id="databricks_default",
+        warehouse_id=WAREHOUSE_ID,
+        wait_for_termination=True,
+    )
+    # [END howto_operator_databricks_start_sql_warehouse]
+
+    # [START howto_operator_databricks_stop_sql_warehouse]
+    stop_warehouse = DatabricksStopWarehouseOperator(
+        task_id="stop_warehouse",
+        databricks_conn_id="databricks_default",
+        warehouse_id=WAREHOUSE_ID,
+        wait_for_termination=True,
+        trigger_rule="all_done",
+    )
+    # [END howto_operator_databricks_stop_sql_warehouse]
+
+    start_warehouse >> stop_warehouse
+
+    from tests_common.test_utils.watcher import watcher
+
+    list(dag.tasks) >> watcher()
+
+from tests_common.test_utils.system_tests import get_test_run  # noqa: E402
+
+test_run = get_test_run(dag)
diff --git 
a/providers/databricks/tests/unit/databricks/hooks/test_databricks.py 
b/providers/databricks/tests/unit/databricks/hooks/test_databricks.py
index 9086e291986..be87a82483c 100644
--- a/providers/databricks/tests/unit/databricks/hooks/test_databricks.py
+++ b/providers/databricks/tests/unit/databricks/hooks/test_databricks.py
@@ -43,6 +43,7 @@ from airflow.providers.databricks.hooks.databricks import (
     DatabricksHook,
     RunState,
     SQLStatementState,
+    WarehouseState,
 )
 from airflow.providers.databricks.hooks.databricks_base import (
     AZURE_MANAGEMENT_ENDPOINT,
@@ -1552,6 +1553,68 @@ class 
TestDatabricksHookConnSettings(TestDatabricksHookToken):
         assert mock_get.call_args.args == 
(f"http://{HOST}:7908/api/2.1/foo/bar";,)
 
 
+class TestWarehouseLifecycle:
+    @mock.patch.object(DatabricksHook, "_do_api_call", autospec=True)
+    def test_get_warehouse_calls_correct_endpoint(self, mock_do_api_call):
+        mock_do_api_call.return_value = {"id": "wh-1", "state": "RUNNING"}
+        hook = DatabricksHook()
+
+        result = hook.get_warehouse("wh-1")
+
+        assert result == {"id": "wh-1", "state": "RUNNING"}
+        mock_do_api_call.assert_called_once_with(hook, ("GET", 
"2.0/sql/warehouses/wh-1"))
+
+    @mock.patch.object(DatabricksHook, "_do_api_call", autospec=True)
+    def test_get_warehouse_state_wraps_state(self, mock_do_api_call):
+        mock_do_api_call.return_value = {"state": "RUNNING"}
+        hook = DatabricksHook()
+
+        state = hook.get_warehouse_state("wh-1")
+
+        assert state == WarehouseState("RUNNING")
+        assert state.is_running
+        mock_do_api_call.assert_called_once_with(hook, ("GET", 
"2.0/sql/warehouses/wh-1"))
+
+    @mock.patch.object(DatabricksHook, "_do_api_call", autospec=True)
+    def test_start_warehouse_endpoint(self, mock_do_api_call):
+        hook = DatabricksHook()
+
+        hook.start_warehouse("wh-1")
+
+        mock_do_api_call.assert_called_once_with(hook, ("POST", 
"2.0/sql/warehouses/wh-1/start"))
+
+    @mock.patch.object(DatabricksHook, "_do_api_call", autospec=True)
+    def test_stop_warehouse_endpoint(self, mock_do_api_call):
+        hook = DatabricksHook()
+
+        hook.stop_warehouse("wh-1")
+
+        mock_do_api_call.assert_called_once_with(hook, ("POST", 
"2.0/sql/warehouses/wh-1/stop"))
+
+    @pytest.mark.parametrize(
+        ("state", "is_running", "is_stopped", "is_deleted"),
+        [
+            ("STARTING", False, False, False),
+            ("RUNNING", True, False, False),
+            ("STOPPING", False, False, False),
+            ("STOPPED", False, True, False),
+            ("DELETING", False, False, True),
+            ("DELETED", False, False, True),
+        ],
+    )
+    def test_warehouse_state_valid_and_properties(self, state, is_running, 
is_stopped, is_deleted):
+        warehouse_state = WarehouseState(state)
+
+        assert warehouse_state.state == state
+        assert warehouse_state.is_running is is_running
+        assert warehouse_state.is_stopped is is_stopped
+        assert warehouse_state.is_deleted is is_deleted
+
+    def test_warehouse_state_unexpected_raises_value_error(self):
+        with pytest.raises(ValueError, match="Unexpected warehouse state: 
FOO"):
+            WarehouseState("FOO")
+
+
 class TestRunState:
     def test_is_terminal_true(self):
         terminal_states = ["TERMINATED", "SKIPPED", "INTERNAL_ERROR"]
diff --git 
a/providers/databricks/tests/unit/databricks/operators/test_warehouse.py 
b/providers/databricks/tests/unit/databricks/operators/test_warehouse.py
new file mode 100644
index 00000000000..aa99e4d54cc
--- /dev/null
+++ b/providers/databricks/tests/unit/databricks/operators/test_warehouse.py
@@ -0,0 +1,406 @@
+# 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 import mock
+
+import pytest
+
+from airflow.providers.databricks.exceptions import DatabricksWarehouseError
+from airflow.providers.databricks.hooks.databricks import DatabricksHook, 
WarehouseState
+from airflow.providers.databricks.operators.warehouse import (
+    DatabricksStartWarehouseOperator,
+    DatabricksStopWarehouseOperator,
+)
+
+TASK_ID = "warehouse-lifecycle"
+WAREHOUSE_ID = "wh-1"
+
+
+class TestDatabricksStartWarehouseOperator:
+    @pytest.mark.parametrize(
+        "first_polled_state",
+        ["STARTING", "STOPPED"],
+        ids=["transitioning", "stale-stopped"],
+    )
+    @mock.patch("airflow.providers.databricks.operators.warehouse.time.sleep")
+    @mock.patch.object(DatabricksStartWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    def test_starts_then_waits_until_running(self, mock_hook_property, 
mock_sleep, first_polled_state):
+        hook = mock.MagicMock(spec=DatabricksHook)
+        mock_hook_property.return_value = hook
+        hook.get_warehouse_state.side_effect = [
+            WarehouseState("STOPPED"),
+            WarehouseState(first_polled_state),
+            WarehouseState("RUNNING"),
+        ]
+        operator = DatabricksStartWarehouseOperator(
+            task_id=TASK_ID,
+            warehouse_id=WAREHOUSE_ID,
+            polling_period_seconds=0,
+        )
+
+        operator.execute(None)
+
+        hook.start_warehouse.assert_called_once_with(WAREHOUSE_ID)
+        assert hook.get_warehouse_state.call_count == 3
+        mock_sleep.assert_called_once_with(0)
+
+    @mock.patch.object(DatabricksStartWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    def test_skips_start_when_already_running(self, mock_hook_property):
+        hook = mock.MagicMock(spec=DatabricksHook)
+        mock_hook_property.return_value = hook
+        hook.get_warehouse_state.return_value = WarehouseState("RUNNING")
+        operator = DatabricksStartWarehouseOperator(task_id=TASK_ID, 
warehouse_id=WAREHOUSE_ID)
+
+        operator.execute(None)
+
+        hook.start_warehouse.assert_not_called()
+        hook.get_warehouse_state.assert_called_once_with(WAREHOUSE_ID)
+
+    @mock.patch.object(DatabricksStartWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    def test_skips_start_when_starting_without_waiting(self, 
mock_hook_property):
+        hook = mock.MagicMock(spec=DatabricksHook)
+        mock_hook_property.return_value = hook
+        hook.get_warehouse_state.return_value = WarehouseState("STARTING")
+        operator = DatabricksStartWarehouseOperator(
+            task_id=TASK_ID,
+            warehouse_id=WAREHOUSE_ID,
+            wait_for_termination=False,
+        )
+
+        operator.execute(None)
+
+        hook.start_warehouse.assert_not_called()
+        hook.get_warehouse_state.assert_called_once_with(WAREHOUSE_ID)
+
+    @mock.patch("airflow.providers.databricks.operators.warehouse.time.sleep")
+    @mock.patch.object(DatabricksStartWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    def test_starts_while_stopping_and_waits_until_running(self, 
mock_hook_property, mock_sleep):
+        hook = mock.MagicMock(spec=DatabricksHook)
+        mock_hook_property.return_value = hook
+        hook.get_warehouse_state.side_effect = [
+            WarehouseState("STOPPING"),
+            WarehouseState("STOPPING"),
+            WarehouseState("RUNNING"),
+        ]
+        operator = DatabricksStartWarehouseOperator(
+            task_id=TASK_ID,
+            warehouse_id=WAREHOUSE_ID,
+            polling_period_seconds=0,
+        )
+
+        operator.execute(None)
+
+        hook.start_warehouse.assert_called_once_with(WAREHOUSE_ID)
+        assert hook.get_warehouse_state.call_count == 3
+        mock_sleep.assert_called_once_with(0)
+
+
+class TestDatabricksStopWarehouseOperator:
+    @mock.patch("airflow.providers.databricks.operators.warehouse.time.sleep")
+    @mock.patch.object(DatabricksStopWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    def test_stops_then_waits_until_stopped(self, mock_hook_property, 
mock_sleep):
+        hook = mock.MagicMock(spec=DatabricksHook)
+        mock_hook_property.return_value = hook
+        hook.get_warehouse_state.side_effect = [
+            WarehouseState("RUNNING"),
+            WarehouseState("STOPPING"),
+            WarehouseState("STOPPED"),
+        ]
+        operator = DatabricksStopWarehouseOperator(
+            task_id=TASK_ID,
+            warehouse_id=WAREHOUSE_ID,
+            polling_period_seconds=0,
+        )
+
+        operator.execute(None)
+
+        hook.stop_warehouse.assert_called_once_with(WAREHOUSE_ID)
+        assert hook.get_warehouse_state.call_count == 3
+        mock_sleep.assert_called_once_with(0)
+
+    @mock.patch.object(DatabricksStopWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    def test_skips_stop_when_already_stopped(self, mock_hook_property):
+        hook = mock.MagicMock(spec=DatabricksHook)
+        mock_hook_property.return_value = hook
+        hook.get_warehouse_state.return_value = WarehouseState("STOPPED")
+        operator = DatabricksStopWarehouseOperator(task_id=TASK_ID, 
warehouse_id=WAREHOUSE_ID)
+
+        operator.execute(None)
+
+        hook.stop_warehouse.assert_not_called()
+        hook.get_warehouse_state.assert_called_once_with(WAREHOUSE_ID)
+
+    @mock.patch.object(DatabricksStopWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    def test_skips_stop_when_stopping_without_waiting(self, 
mock_hook_property):
+        hook = mock.MagicMock(spec=DatabricksHook)
+        mock_hook_property.return_value = hook
+        hook.get_warehouse_state.return_value = WarehouseState("STOPPING")
+        operator = DatabricksStopWarehouseOperator(
+            task_id=TASK_ID,
+            warehouse_id=WAREHOUSE_ID,
+            wait_for_termination=False,
+        )
+
+        operator.execute(None)
+
+        hook.stop_warehouse.assert_not_called()
+        hook.get_warehouse_state.assert_called_once_with(WAREHOUSE_ID)
+
+
+class TestDatabricksWarehouseOperatorBase:
+    @pytest.mark.parametrize(
+        ("operator_class", "initial_state", "failure_state", 
"transition_method", "target_state"),
+        [
+            (DatabricksStartWarehouseOperator, "STOPPED", "DELETING", 
"start_warehouse", "RUNNING"),
+            (DatabricksStopWarehouseOperator, "RUNNING", "DELETED", 
"stop_warehouse", "STOPPED"),
+        ],
+    )
+    @mock.patch.object(DatabricksStartWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    @mock.patch.object(DatabricksStopWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    def test_execute_raises_on_failure_state(
+        self,
+        mock_stop_hook_property,
+        mock_start_hook_property,
+        operator_class,
+        initial_state,
+        failure_state,
+        transition_method,
+        target_state,
+    ):
+        hook = mock.MagicMock(spec=DatabricksHook)
+        mock_start_hook_property.return_value = hook
+        mock_stop_hook_property.return_value = hook
+        hook.get_warehouse_state.side_effect = [
+            WarehouseState(initial_state),
+            WarehouseState(failure_state),
+        ]
+        operator = operator_class(task_id=TASK_ID, warehouse_id=WAREHOUSE_ID)
+
+        with pytest.raises(
+            DatabricksWarehouseError,
+            match=f"entered {failure_state} while waiting for {target_state}",
+        ):
+            operator.execute(None)
+
+        getattr(hook, transition_method).assert_called_once_with(WAREHOUSE_ID)
+
+    @pytest.mark.parametrize(
+        ("operator_class", "initial_state", "target_state", 
"transition_method"),
+        [
+            (DatabricksStartWarehouseOperator, "STARTING", "RUNNING", 
"start_warehouse"),
+            (DatabricksStopWarehouseOperator, "STOPPING", "STOPPED", 
"stop_warehouse"),
+        ],
+    )
+    @mock.patch("airflow.providers.databricks.operators.warehouse.time.sleep")
+    @mock.patch.object(DatabricksStartWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    @mock.patch.object(DatabricksStopWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    def test_transition_in_progress_skips_request_and_continues_waiting(
+        self,
+        mock_stop_hook_property,
+        mock_start_hook_property,
+        mock_sleep,
+        operator_class,
+        initial_state,
+        target_state,
+        transition_method,
+    ):
+        hook = mock.MagicMock(spec=DatabricksHook)
+        mock_start_hook_property.return_value = hook
+        mock_stop_hook_property.return_value = hook
+        hook.get_warehouse_state.side_effect = [
+            WarehouseState(initial_state),
+            WarehouseState(initial_state),
+            WarehouseState(target_state),
+        ]
+        operator = operator_class(
+            task_id=TASK_ID,
+            warehouse_id=WAREHOUSE_ID,
+            polling_period_seconds=0,
+        )
+
+        operator.execute(None)
+
+        getattr(hook, transition_method).assert_not_called()
+        assert hook.get_warehouse_state.call_count == 3
+        mock_sleep.assert_called_once_with(0)
+
+    @pytest.mark.parametrize(
+        ("operator_class", "initial_state", "transition_method"),
+        [
+            (DatabricksStartWarehouseOperator, "STOPPED", "start_warehouse"),
+            (DatabricksStopWarehouseOperator, "RUNNING", "stop_warehouse"),
+        ],
+    )
+    @mock.patch.object(DatabricksStartWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    @mock.patch.object(DatabricksStopWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    def test_transition_without_waiting(
+        self,
+        mock_stop_hook_property,
+        mock_start_hook_property,
+        operator_class,
+        initial_state,
+        transition_method,
+    ):
+        hook = mock.MagicMock(spec=DatabricksHook)
+        mock_start_hook_property.return_value = hook
+        mock_stop_hook_property.return_value = hook
+        hook.get_warehouse_state.return_value = WarehouseState(initial_state)
+        operator = operator_class(
+            task_id=TASK_ID,
+            warehouse_id=WAREHOUSE_ID,
+            wait_for_termination=False,
+        )
+
+        operator.execute(None)
+
+        getattr(hook, transition_method).assert_called_once_with(WAREHOUSE_ID)
+        hook.get_warehouse_state.assert_called_once_with(WAREHOUSE_ID)
+
+    @mock.patch("airflow.providers.databricks.operators.warehouse.time.sleep")
+    @mock.patch(
+        "airflow.providers.databricks.operators.warehouse.time.monotonic",
+        return_value=0,
+    )
+    @mock.patch.object(DatabricksStartWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    def test_wait_caps_sleep_and_does_not_poll_after_deadline(
+        self, mock_hook_property, mock_monotonic, mock_sleep
+    ):
+        hook = mock.MagicMock(spec=DatabricksHook)
+        mock_hook_property.return_value = hook
+        hook.get_warehouse_state.side_effect = [WarehouseState("STARTING"), 
WarehouseState("RUNNING")]
+        operator = DatabricksStartWarehouseOperator(
+            task_id=TASK_ID,
+            warehouse_id=WAREHOUSE_ID,
+            polling_period_seconds=30,
+            timeout=10,
+        )
+
+        def advance_to_deadline(seconds):
+            mock_monotonic.return_value = seconds
+
+        mock_sleep.side_effect = advance_to_deadline
+
+        with pytest.raises(
+            DatabricksWarehouseError,
+            match="did not reach RUNNING within 10s; last state: STARTING",
+        ):
+            operator._wait_for_state("RUNNING")
+
+        hook.get_warehouse_state.assert_called_once_with(WAREHOUSE_ID)
+        mock_sleep.assert_called_once_with(10)
+
+    @mock.patch("airflow.providers.databricks.operators.warehouse.time.sleep")
+    @mock.patch(
+        "airflow.providers.databricks.operators.warehouse.time.monotonic",
+        return_value=0,
+    )
+    @mock.patch.object(DatabricksStartWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    def test_wait_accepts_target_received_after_deadline(
+        self, mock_hook_property, mock_monotonic, mock_sleep
+    ):
+        hook = mock.MagicMock(spec=DatabricksHook)
+        mock_hook_property.return_value = hook
+
+        def get_state_after_deadline(_):
+            mock_monotonic.return_value = 11
+            return WarehouseState("RUNNING")
+
+        hook.get_warehouse_state.side_effect = get_state_after_deadline
+        operator = DatabricksStartWarehouseOperator(
+            task_id=TASK_ID,
+            warehouse_id=WAREHOUSE_ID,
+            timeout=10,
+        )
+
+        operator._wait_for_state("RUNNING")
+
+        hook.get_warehouse_state.assert_called_once_with(WAREHOUSE_ID)
+        mock_sleep.assert_not_called()
+
+    @mock.patch("airflow.providers.databricks.operators.warehouse.time.sleep")
+    @mock.patch(
+        "airflow.providers.databricks.operators.warehouse.time.monotonic",
+        return_value=0,
+    )
+    @mock.patch.object(DatabricksStartWarehouseOperator, "_hook", 
new_callable=mock.PropertyMock)
+    def test_wait_rejects_deleted_state_received_after_deadline(
+        self, mock_hook_property, mock_monotonic, mock_sleep
+    ):
+        hook = mock.MagicMock(spec=DatabricksHook)
+        mock_hook_property.return_value = hook
+
+        def get_state_after_deadline(_):
+            mock_monotonic.return_value = 11
+            return WarehouseState("DELETED")
+
+        hook.get_warehouse_state.side_effect = get_state_after_deadline
+        operator = DatabricksStartWarehouseOperator(
+            task_id=TASK_ID,
+            warehouse_id=WAREHOUSE_ID,
+            timeout=10,
+        )
+
+        with pytest.raises(
+            DatabricksWarehouseError,
+            match="entered DELETED while waiting for RUNNING",
+        ):
+            operator._wait_for_state("RUNNING")
+
+        hook.get_warehouse_state.assert_called_once_with(WAREHOUSE_ID)
+        mock_sleep.assert_not_called()
+
+    def test_operator_template_fields(self):
+        expected = ("databricks_conn_id", "warehouse_id")
+        assert DatabricksStartWarehouseOperator.template_fields == expected
+        assert DatabricksStopWarehouseOperator.template_fields == expected
+
+    @pytest.mark.parametrize(
+        ("operator_class", "warehouse_id"),
+        [
+            (DatabricksStartWarehouseOperator, ""),
+            (DatabricksStartWarehouseOperator, None),
+            (DatabricksStopWarehouseOperator, ""),
+            (DatabricksStopWarehouseOperator, None),
+        ],
+    )
+    def test_invalid_warehouse_id(self, operator_class, warehouse_id):
+        operator = operator_class(task_id=TASK_ID, warehouse_id=warehouse_id)
+
+        with pytest.raises(ValueError, match="warehouse_id must be provided"):
+            operator.execute(None)
+
+    
@mock.patch("airflow.providers.databricks.operators.warehouse.DatabricksHook", 
autospec=True)
+    def test_operator_builds_hook(self, mock_hook_class):
+        retry_args = {"reraise": True}
+        operator = DatabricksStartWarehouseOperator(
+            task_id=TASK_ID,
+            warehouse_id=WAREHOUSE_ID,
+            databricks_conn_id="custom_conn",
+            databricks_retry_limit=7,
+            databricks_retry_delay=4,
+            databricks_retry_args=retry_args,
+        )
+
+        assert operator._hook is mock_hook_class.return_value
+        mock_hook_class.assert_called_once_with(
+            "custom_conn",
+            retry_limit=7,
+            retry_delay=4,
+            retry_args=retry_args,
+            caller="DatabricksStartWarehouseOperator",
+        )

Reply via email to