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

vincbeck 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 c8d890852cb Add SnowparkContainerJobOperator (#68259)
c8d890852cb is described below

commit c8d890852cbe73db98024df2c3d27ba3aafee9eb
Author: Justin Pakzad <[email protected]>
AuthorDate: Mon Jul 6 11:31:07 2026 -0400

    Add SnowparkContainerJobOperator (#68259)
    
    This PR adds SnowparkContainerJobOperator which:
    
    - Submits the job asynchronously via EXECUTE JOB SERVICE
    - Polls DESCRIBE SERVICE until the job reaches a terminal state (DONE, 
FAILED, etc.)
    - Fetches container logs via SYSTEM$GET_SERVICE_LOGS for all replicas and 
surfaces them in the Airflow task logs
    - Supports both stage-based specs (spec + spec_stage) and inline YAML 
(spec_text)
    - Supports fire-and-forget mode (wait_for_completion=False) for 
long-running jobs as well as other optional parameters (drop_on_completion, 
replicas, query_warehouse, etc.)
---
 .../docs/operators/snowpark_containers.rst         |  69 +++++
 providers/snowflake/provider.yaml                  |   2 +
 .../providers/snowflake/get_provider_info.py       |   2 +
 .../snowflake/operators/snowpark_containers.py     | 251 ++++++++++++++++++
 .../snowflake/example_snowpark_container_job.py    |  54 ++++
 .../operators/test_snowpark_containers.py          | 282 +++++++++++++++++++++
 6 files changed, 660 insertions(+)

diff --git a/providers/snowflake/docs/operators/snowpark_containers.rst 
b/providers/snowflake/docs/operators/snowpark_containers.rst
new file mode 100644
index 00000000000..8677d16207c
--- /dev/null
+++ b/providers/snowflake/docs/operators/snowpark_containers.rst
@@ -0,0 +1,69 @@
+ .. 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:SnowparkContainerJobOperator:
+
+SnowparkContainerJobOperator
+============================
+
+Use the :class:`SnowparkContainerJobOperator
+<airflow.providers.snowflake.operators.snowpark_containers.SnowparkContainerJobOperator>`
 to
+submit and monitor container jobs on
+`Snowpark Container Services 
<https://docs.snowflake.com/en/developer-guide/snowpark-container-services/overview>`__.
+
+The operator wraps the Snowflake
+`EXECUTE JOB SERVICE 
<https://docs.snowflake.com/en/sql-reference/sql/execute-job-service>`__
+SQL command. It submits the job asynchronously, optionally polls until the job
+reaches a terminal state, retrieves container logs, and can drop the service on
+completion.
+
+Prerequisite Tasks
+^^^^^^^^^^^^^^^^^^
+
+To use this operator, you must do a few things:
+
+  * Install the provider package via **pip**.
+
+    .. code-block:: bash
+
+      pip install 'apache-airflow-providers-snowflake'
+
+    Detailed information is available for :doc:`Installation 
<apache-airflow:installation/index>`.
+
+  * :doc:`Setup a Snowflake Connection </connections/snowflake>`.
+
+  * Create the necessary resources in your Snowflake account. See the
+    `Snowpark Container Services documentation 
<https://docs.snowflake.com/en/developer-guide/snowpark-container-services/overview>`__.
+
+Using the Operator
+^^^^^^^^^^^^^^^^^^
+
+Use the ``snowflake_conn_id`` argument to specify the connection used. If not
+specified, ``snowflake_default`` will be used.
+
+An example usage of the SnowparkContainerJobOperator is as follows:
+
+.. exampleinclude:: 
/../../snowflake/tests/system/snowflake/example_snowpark_container_job.py
+    :language: python
+    :start-after: [START howto_operator_snowpark_container_job]
+    :end-before: [END howto_operator_snowpark_container_job]
+    :dedent: 4
+
+.. note::
+
+  Parameters that can be passed onto the operator will be given priority over 
the parameters already given
+  in the Airflow connection metadata (such as ``schema``, ``role``, 
``database`` and so forth).
diff --git a/providers/snowflake/provider.yaml 
b/providers/snowflake/provider.yaml
index 42a832fc34f..af2de596afd 100644
--- a/providers/snowflake/provider.yaml
+++ b/providers/snowflake/provider.yaml
@@ -118,6 +118,7 @@ integrations:
     how-to-guide:
       - /docs/apache-airflow-providers-snowflake/operators/snowflake.rst
       - /docs/apache-airflow-providers-snowflake/operators/snowpark.rst
+      - 
/docs/apache-airflow-providers-snowflake/operators/snowpark_containers.rst
     logo: /docs/integration-logos/Snowflake.png
     tags: [service]
 
@@ -126,6 +127,7 @@ operators:
     python-modules:
       - airflow.providers.snowflake.operators.snowflake
       - airflow.providers.snowflake.operators.snowpark
+      - airflow.providers.snowflake.operators.snowpark_containers
 
 task-decorators:
   - class-name: airflow.providers.snowflake.decorators.snowpark.snowpark_task
diff --git 
a/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py 
b/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py
index c6c4ffd84d4..d60e1d9eee0 100644
--- a/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py
+++ b/providers/snowflake/src/airflow/providers/snowflake/get_provider_info.py
@@ -33,6 +33,7 @@ def get_provider_info():
                 "how-to-guide": [
                     
"/docs/apache-airflow-providers-snowflake/operators/snowflake.rst",
                     
"/docs/apache-airflow-providers-snowflake/operators/snowpark.rst",
+                    
"/docs/apache-airflow-providers-snowflake/operators/snowpark_containers.rst",
                 ],
                 "logo": "/docs/integration-logos/Snowflake.png",
                 "tags": ["service"],
@@ -44,6 +45,7 @@ def get_provider_info():
                 "python-modules": [
                     "airflow.providers.snowflake.operators.snowflake",
                     "airflow.providers.snowflake.operators.snowpark",
+                    
"airflow.providers.snowflake.operators.snowpark_containers",
                 ],
             }
         ],
diff --git 
a/providers/snowflake/src/airflow/providers/snowflake/operators/snowpark_containers.py
 
b/providers/snowflake/src/airflow/providers/snowflake/operators/snowpark_containers.py
new file mode 100644
index 00000000000..971be06543a
--- /dev/null
+++ 
b/providers/snowflake/src/airflow/providers/snowflake/operators/snowpark_containers.py
@@ -0,0 +1,251 @@
+# 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 time
+from collections.abc import Sequence
+from enum import Enum
+from functools import cached_property
+from typing import TYPE_CHECKING, Any
+
+from airflow.providers.common.compat.standard.operators import BaseOperator
+from airflow.providers.common.sql.hooks.handlers import fetch_one_handler
+from airflow.providers.snowflake.hooks.snowflake import SnowflakeHook
+
+if TYPE_CHECKING:
+    from airflow.providers.common.compat.sdk import Context
+
+
+class SnowparkContainerJobStatus(str, Enum):
+    """Statuses of a Snowpark Container Services."""
+
+    PENDING = "PENDING"
+    RUNNING = "RUNNING"
+    CANCELLING = "CANCELLING"
+    SUSPENDING = "SUSPENDING"
+    DELETING = "DELETING"
+    DONE = "DONE"
+    FAILED = "FAILED"
+    CANCELLED = "CANCELLED"
+    INTERNAL_ERROR = "INTERNAL_ERROR"
+
+
+TERMINAL_STATUSES: frozenset[SnowparkContainerJobStatus] = frozenset(
+    {
+        SnowparkContainerJobStatus.DONE,
+        SnowparkContainerJobStatus.FAILED,
+        SnowparkContainerJobStatus.CANCELLED,
+        SnowparkContainerJobStatus.INTERNAL_ERROR,
+    }
+)
+NON_TERMINAL_STATUSES: frozenset[SnowparkContainerJobStatus] = frozenset(
+    {
+        SnowparkContainerJobStatus.PENDING,
+        SnowparkContainerJobStatus.RUNNING,
+        SnowparkContainerJobStatus.CANCELLING,
+        SnowparkContainerJobStatus.SUSPENDING,
+        SnowparkContainerJobStatus.DELETING,
+    }
+)
+
+
+class SnowparkContainerJobOperator(BaseOperator):
+    """
+    Execute a job on Snowpark Container Services.
+
+    Submits a container job to a compute pool via ``EXECUTE JOB SERVICE``,
+    optionally polls for completion, retrieves container logs, and
+    drops the job service on success.
+
+    .. seealso::
+        `Snowpark Container Services 
<https://docs.snowflake.com/en/developer-guide/snowpark-container-services/overview>`_
+
+    :param compute_pool: name of the compute pool to run the job on
+    :param container_name: container name as defined in the service 
specification file,
+        used for retrieving container logs
+    :param spec: spec filename on the stage (e.g. ``'spec.yaml'``).
+        Must be provided together with ``spec_stage``
+    :param spec_stage: stage where the spec file is stored (e.g. 
``'@my_stage'``).
+        Must be provided together with ``spec``
+    :param spec_text: inline YAML spec text, as an alternative to 
``spec``/``spec_stage``.
+        The text is wrapped in ``$$`` delimiters automatically
+    :param name: (Optional) job service name. If not provided, Snowflake
+        auto-generates a name
+    :param query_warehouse: (Optional) warehouse for SQL queries run inside 
the container.
+        This is separate from the ``warehouse`` parameter used by the 
operator's
+        own SQL commands
+    :param replicas: (Optional) number of job replicas to run. (default value: 
1)
+    :param wait_for_completion: poll until the job reaches a terminal state.
+        When disabled, the job is submitted and the operator returns
+        immediately. (default value: True)
+    :param drop_on_completion: drop the job service after the job finishes
+        successfully. Failed jobs are not dropped, allowing inspection
+        in Snowflake. (default value: True)
+    :param poll_interval: the interval in seconds to poll the query status.
+        (default value: 10)
+    :param snowflake_conn_id: Reference to
+        :ref:`Snowflake connection id<howto/connection:snowflake>`
+    :param database: name of database (will overwrite database defined
+        in connection)
+    :param schema: name of schema (will overwrite schema defined in
+        connection)
+    :param role: name of role (will overwrite any role defined in
+        connection's extra JSON)
+    :param warehouse: name of warehouse (will overwrite any warehouse
+        defined in the connection's extra JSON). Used for the operator's
+        own SQL commands, not for the container's queries
+    """
+
+    template_fields: Sequence[str] = (
+        "compute_pool",
+        "spec",
+        "spec_stage",
+        "container_name",
+        "spec_text",
+        "name",
+        "query_warehouse",
+        "snowflake_conn_id",
+    )
+
+    def __init__(
+        self,
+        *,
+        compute_pool: str,
+        container_name: str,
+        spec: str | None = None,
+        spec_stage: str | None = None,
+        spec_text: str | None = None,
+        name: str | None = None,
+        query_warehouse: str | None = None,
+        replicas: int = 1,
+        wait_for_completion: bool = True,
+        drop_on_completion: bool = True,
+        poll_interval: int = 10,
+        snowflake_conn_id: str = "snowflake_default",
+        database: str | None = None,
+        schema: str | None = None,
+        role: str | None = None,
+        warehouse: str | None = None,
+        **kwargs: Any,
+    ) -> None:
+        super().__init__(**kwargs)
+        self.compute_pool = compute_pool
+        self.container_name = container_name
+        self.spec = spec
+        self.spec_stage = spec_stage
+        self.spec_text = spec_text
+        self.name = name
+        self.query_warehouse = query_warehouse
+        self.replicas = replicas
+        self.wait_for_completion = wait_for_completion
+        self.drop_on_completion = drop_on_completion
+        self.poll_interval = poll_interval
+        self.snowflake_conn_id = snowflake_conn_id
+        self.database = database
+        self.schema = schema
+        self.role = role
+        self.warehouse = warehouse
+        # Set after the job is submitted, parsed from the job submission 
response.
+        self.job_name: str | None = None
+
+        if self.spec_text and (self.spec or self.spec_stage):
+            raise ValueError("Cannot specify both 'spec_text' and 
'spec'/'spec_stage'")
+        if not self.spec_text and not (self.spec and self.spec_stage):
+            raise ValueError("Must provide either 'spec_text' or both 'spec' 
and 'spec_stage'")
+
+    @cached_property
+    def _hook(self) -> SnowflakeHook:
+        return SnowflakeHook(
+            snowflake_conn_id=self.snowflake_conn_id,
+            warehouse=self.warehouse,
+            database=self.database,
+            schema=self.schema,
+            role=self.role,
+        )
+
+    def _build_sql(self) -> str:
+        """Build the execute job SQL statement."""
+        sql = f"EXECUTE JOB SERVICE IN COMPUTE POOL {self.compute_pool}"
+        if self.name:
+            sql += f" NAME = {self.name}"
+        sql += " ASYNC = TRUE"
+        if self.replicas > 1:
+            sql += f" REPLICAS = {self.replicas}"
+        if self.query_warehouse:
+            sql += f" QUERY_WAREHOUSE = {self.query_warehouse}"
+        if self.spec_text:
+            sql += f" FROM SPECIFICATION $${self.spec_text}$$"
+        else:
+            sql += f" FROM {self.spec_stage} SPEC = '{self.spec}'"
+        return sql
+
+    def _run_one(self, sql: str, return_dictionaries: bool = False) -> Any:
+        """Run a single statement that returns one row via 
fetch_one_handler."""
+        return self._hook.run(sql, handler=fetch_one_handler, 
return_dictionaries=return_dictionaries)
+
+    def _submit_job(self) -> str:
+        """Submit the job and return the name."""
+        response = self._run_one(self._build_sql())
+        job_name = response[0].split("'")[1]
+        return job_name
+
+    def _poll_for_status(self) -> str:
+        """Poll until the job reaches a terminal state."""
+        while True:
+            response = self._run_one(f"DESCRIBE SERVICE {self.job_name}", 
return_dictionaries=True)
+            status = response.get("status")
+            if status in TERMINAL_STATUSES:
+                return status
+            if status not in NON_TERMINAL_STATUSES:
+                raise RuntimeError(f"Job {self.job_name} returned unexpected 
status: {status}")
+            time.sleep(self.poll_interval)
+
+    def _log_container_output(self, status: str) -> None:
+        """Fetch and log container output for all replicas."""
+        for instance_id in range(self.replicas):
+            sql = f"SELECT SYSTEM$GET_SERVICE_LOGS('{self.job_name}', 
{instance_id}, '{self.container_name}')"
+            response = self._run_one(sql)[0]
+            if not response:
+                continue
+            if status != SnowparkContainerJobStatus.DONE:
+                self.log.error("Logs for instance_id %d:\n%s", instance_id, 
response)
+            else:
+                self.log.info("Logs for instance_id %d:\n%s", instance_id, 
response)
+
+    def on_kill(self) -> None:
+        """Drop the running service on task kill."""
+        if self.job_name:
+            try:
+                self._hook.run(f"DROP SERVICE IF EXISTS {self.job_name}")
+            except Exception as e:
+                self.log.error("Error dropping service %s: %s", self.job_name, 
e)
+
+    def execute(self, context: Context) -> str:
+        """Submit and optionally wait for a Snowpark Container Services job."""
+        self.job_name = self._submit_job()
+        if not self.job_name:
+            raise RuntimeError("Job name was not returned")
+        if not self.wait_for_completion:
+            return self.job_name
+        status = self._poll_for_status()
+        self._log_container_output(status)
+        if status != SnowparkContainerJobStatus.DONE:
+            raise RuntimeError(f"Job '{self.job_name}' finished with status: 
{status}")
+        if self.drop_on_completion:
+            self._hook.run(f"DROP SERVICE IF EXISTS {self.job_name}")
+        return self.job_name
diff --git 
a/providers/snowflake/tests/system/snowflake/example_snowpark_container_job.py 
b/providers/snowflake/tests/system/snowflake/example_snowpark_container_job.py
new file mode 100644
index 00000000000..8c9a1a72f1f
--- /dev/null
+++ 
b/providers/snowflake/tests/system/snowflake/example_snowpark_container_job.py
@@ -0,0 +1,54 @@
+#
+# 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 use of SnowparkContainerJobOperator.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from airflow import DAG
+from airflow.providers.snowflake.operators.snowpark_containers import 
SnowparkContainerJobOperator
+
+SNOWFLAKE_CONN_ID = "my_snowflake_conn"
+DAG_ID = "example_snowpark_container_job"
+
+with DAG(
+    DAG_ID,
+    start_date=datetime(2024, 1, 1),
+    schedule="@once",
+    default_args={"snowflake_conn_id": SNOWFLAKE_CONN_ID},
+    tags=["example"],
+    catchup=False,
+) as dag:
+    # [START howto_operator_snowpark_container_job]
+    run_job = SnowparkContainerJobOperator(
+        task_id="run_job",
+        compute_pool="my_compute_pool",
+        container_name="main",
+        spec="spec.yaml",
+        spec_stage="@my_stage",
+    )
+    # [END howto_operator_snowpark_container_job]
+
+
+from tests_common.test_utils.system_tests import get_test_run  # noqa: E402
+
+# Needed to run the example DAG with pytest (see: 
contributing-docs/testing/system_tests.rst)
+test_run = get_test_run(dag)
diff --git 
a/providers/snowflake/tests/unit/snowflake/operators/test_snowpark_containers.py
 
b/providers/snowflake/tests/unit/snowflake/operators/test_snowpark_containers.py
new file mode 100644
index 00000000000..3b10bfea06d
--- /dev/null
+++ 
b/providers/snowflake/tests/unit/snowflake/operators/test_snowpark_containers.py
@@ -0,0 +1,282 @@
+# 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.snowflake.operators.snowpark_containers import 
SnowparkContainerJobOperator
+
+TASK_ID = "test_spcs_job"
+COMPUTE_POOL = "test_pool"
+CONTAINER_NAME = "main"
+SPEC = "spec.yaml"
+SPEC_STAGE = "@test_stage"
+SPEC_TEXT = "spec:\n  containers:\n  - name: main\n    image: 
/db/schema/repo/img:latest"
+JOB_NAME = "TEST_JOB"
+SNOWFLAKE_CONN_ID = "snowflake_default"
+MOCK_HOOK_PATH = 
"airflow.providers.snowflake.operators.snowpark_containers.SnowflakeHook"
+
+
+def _make_operator(**kwargs):
+    defaults = {
+        "task_id": TASK_ID,
+        "compute_pool": COMPUTE_POOL,
+        "container_name": CONTAINER_NAME,
+        "spec": SPEC,
+        "spec_stage": SPEC_STAGE,
+    }
+    defaults.update(kwargs)
+    return SnowparkContainerJobOperator(**defaults)
+
+
+class TestSnowparkContainerJobOperator:
+    @pytest.mark.parametrize(
+        ("kwargs", "match"),
+        (
+            pytest.param(
+                {"spec": None, "spec_stage": None, "spec_text": None},
+                "Must provide either",
+                id="no_spec_provided",
+            ),
+            pytest.param(
+                {"spec": SPEC, "spec_stage": SPEC_STAGE, "spec_text": 
SPEC_TEXT},
+                "Cannot specify both",
+                id="both_spec_and_spec_text",
+            ),
+            pytest.param(
+                {"spec": SPEC, "spec_stage": None, "spec_text": None},
+                "Must provide either",
+                id="spec_without_stage",
+            ),
+            pytest.param(
+                {"spec": None, "spec_stage": SPEC_STAGE, "spec_text": None},
+                "Must provide either",
+                id="stage_without_spec",
+            ),
+        ),
+    )
+    def test_invalid_spec_combinations(self, kwargs, match):
+        with pytest.raises(ValueError, match=match):
+            _make_operator(**kwargs)
+
+    def test_build_sql_with_spec_stage(self):
+        op = _make_operator()
+        sql = op._build_sql()
+        assert sql == (
+            f"EXECUTE JOB SERVICE IN COMPUTE POOL {COMPUTE_POOL}"
+            " ASYNC = TRUE"
+            f" FROM {SPEC_STAGE} SPEC = '{SPEC}'"
+        )
+
+    def test_build_sql_with_spec_text(self):
+        op = _make_operator(spec=None, spec_stage=None, spec_text=SPEC_TEXT)
+        sql = op._build_sql()
+        assert sql == (
+            f"EXECUTE JOB SERVICE IN COMPUTE POOL {COMPUTE_POOL}"
+            " ASYNC = TRUE"
+            f" FROM SPECIFICATION $${SPEC_TEXT}$$"
+        )
+
+    @pytest.mark.parametrize(
+        ("kwargs", "expected"),
+        (
+            pytest.param({"name": "my_job"}, "NAME = my_job", id="name"),
+            pytest.param({"replicas": 5}, "REPLICAS = 5", id="replicas"),
+            pytest.param(
+                {"query_warehouse": "COMPUTE_WH"}, "QUERY_WAREHOUSE = 
COMPUTE_WH", id="query_warehouse"
+            ),
+        ),
+    )
+    def test_build_sql_optional_params(self, kwargs, expected):
+        op = _make_operator(**kwargs)
+        assert expected in op._build_sql()
+
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_submit_job_parses_job_name(self, mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        mock_hook.run.return_value = ["Started Snowpark Container Services Job 
'TEST_JOB'."]
+        op = _make_operator()
+        result = op._submit_job()
+        assert result == JOB_NAME
+
+    @pytest.mark.parametrize(
+        "status",
+        ("DONE", "FAILED", "CANCELLED", "INTERNAL_ERROR"),
+    )
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_poll_returns_terminal_status(self, mock_hook_cls, status):
+        mock_hook = mock_hook_cls.return_value
+        mock_hook.run.return_value = {"status": status}
+        op = _make_operator(poll_interval=0)
+        op.job_name = JOB_NAME
+        assert op._poll_for_status() == status
+
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_poll_raises_on_unexpected_status(self, mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        mock_hook.run.return_value = {"status": "UNKNOWN"}
+        op = _make_operator(poll_interval=0)
+        op.job_name = JOB_NAME
+        with pytest.raises(RuntimeError, match="unexpected status"):
+            op._poll_for_status()
+
+    @mock.patch("time.sleep")
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_poll_waits_through_pending_then_done(self, mock_hook_cls, 
mock_sleep):
+        mock_hook = mock_hook_cls.return_value
+        mock_hook.run.side_effect = [
+            {"status": "PENDING"},
+            {"status": "RUNNING"},
+            {"status": "DONE"},
+        ]
+        op = _make_operator(poll_interval=5)
+        op.job_name = JOB_NAME
+        assert op._poll_for_status() == "DONE"
+        assert mock_sleep.call_count == 2
+
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_log_container_output_uses_info_on_done(self, mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        mock_hook.run.return_value = ["container output here"]
+        op = _make_operator()
+        op.job_name = JOB_NAME
+        with mock.patch.object(op.log, "info") as mock_info:
+            op._log_container_output("DONE")
+        mock_info.assert_called_once_with("Logs for instance_id %d:\n%s", 0, 
"container output here")
+
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_log_container_output_uses_error_on_failure(self, mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        mock_hook.run.return_value = ["container output here"]
+        op = _make_operator()
+        op.job_name = JOB_NAME
+        with mock.patch.object(op.log, "error") as mock_error:
+            op._log_container_output("FAILED")
+        mock_error.assert_called_once_with("Logs for instance_id %d:\n%s", 0, 
"container output here")
+
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_log_container_output_no_logs_skips(self, mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        mock_hook.run.return_value = [""]
+        op = _make_operator()
+        op.job_name = JOB_NAME
+        with mock.patch.object(op.log, "info") as mock_info, 
mock.patch.object(op.log, "error") as mock_error:
+            op._log_container_output("DONE")
+        mock_info.assert_not_called()
+        mock_error.assert_not_called()
+
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_log_container_output_multiple_replicas(self, mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        mock_hook.run.return_value = ["logs"]
+        op = _make_operator(replicas=3)
+        op.job_name = JOB_NAME
+        with mock.patch.object(op.log, "info") as mock_info:
+            op._log_container_output("DONE")
+        assert mock_info.call_count == 3
+
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_on_kill_no_job_name(self, mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        op = _make_operator()
+        op.on_kill()
+        mock_hook.run.assert_not_called()
+
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_on_kill_drops_service(self, mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        op = _make_operator()
+        op.job_name = JOB_NAME
+        op.on_kill()
+        mock_hook.run.assert_called_once_with(f"DROP SERVICE IF EXISTS 
{JOB_NAME}")
+
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_on_kill_logs_error_on_exception(self, mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        mock_hook.run.side_effect = Exception("drop failed")
+        op = _make_operator()
+        op.job_name = JOB_NAME
+        with mock.patch.object(op.log, "error") as mock_error:
+            op.on_kill()
+        mock_error.assert_called_once()
+
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_submit_job_raises_on_malformed_response(self, mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        mock_hook.run.return_value = ["unexpected response"]
+        op = _make_operator()
+        with pytest.raises(IndexError):
+            op._submit_job()
+
+    @mock.patch.object(SnowparkContainerJobOperator, "_submit_job", 
return_value=None)
+    def test_execute_raises_when_job_name_not_returned(self, mock_submit):
+        op = _make_operator()
+        with pytest.raises(RuntimeError, match="Job name was not returned"):
+            op.execute(context=None)
+
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_execute_no_wait(self, mock_hook_cls):
+        mock_hook = mock_hook_cls.return_value
+        mock_hook.run.return_value = ["Started Snowpark Container Services Job 
'TEST_JOB'."]
+        op = _make_operator(wait_for_completion=False)
+        result = op.execute(context=None)
+        assert result == JOB_NAME
+        assert mock_hook.run.call_count == 1
+
+    @mock.patch(MOCK_HOOK_PATH)
+    @mock.patch.object(SnowparkContainerJobOperator, "_log_container_output")
+    @mock.patch.object(SnowparkContainerJobOperator, "_poll_for_status", 
return_value="DONE")
+    @mock.patch.object(SnowparkContainerJobOperator, "_submit_job", 
return_value=JOB_NAME)
+    def test_execute_wait_success(self, mock_submit, mock_poll, mock_log, 
mock_hook_cls):
+        op = _make_operator()
+        result = op.execute(context=None)
+        mock_submit.assert_called_once()
+        mock_poll.assert_called_once()
+        mock_log.assert_called_once_with("DONE")
+        assert result == JOB_NAME
+
+    @mock.patch(MOCK_HOOK_PATH)
+    @mock.patch.object(SnowparkContainerJobOperator, "_log_container_output")
+    @mock.patch.object(SnowparkContainerJobOperator, "_poll_for_status", 
return_value="FAILED")
+    @mock.patch.object(SnowparkContainerJobOperator, "_submit_job", 
return_value=JOB_NAME)
+    def test_execute_wait_failure_raises(self, mock_submit, mock_poll, 
mock_log, mock_hook_cls):
+        op = _make_operator()
+        with pytest.raises(RuntimeError, match="FAILED"):
+            op.execute(context=None)
+        mock_log.assert_called_once_with("FAILED")
+
+    @mock.patch.object(SnowparkContainerJobOperator, "_log_container_output")
+    @mock.patch.object(SnowparkContainerJobOperator, "_poll_for_status", 
return_value="DONE")
+    @mock.patch.object(SnowparkContainerJobOperator, "_submit_job", 
return_value=JOB_NAME)
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_execute_drops_service_on_completion(self, mock_hook_cls, 
mock_submit, mock_poll, mock_log):
+        mock_hook = mock_hook_cls.return_value
+        op = _make_operator(drop_on_completion=True)
+        op.execute(context=None)
+        mock_hook.run.assert_called_once_with(f"DROP SERVICE IF EXISTS 
{JOB_NAME}")
+
+    @mock.patch.object(SnowparkContainerJobOperator, "_log_container_output")
+    @mock.patch.object(SnowparkContainerJobOperator, "_poll_for_status", 
return_value="DONE")
+    @mock.patch.object(SnowparkContainerJobOperator, "_submit_job", 
return_value=JOB_NAME)
+    @mock.patch(MOCK_HOOK_PATH)
+    def test_execute_skips_drop_when_disabled(self, mock_hook_cls, 
mock_submit, mock_poll, mock_log):
+        mock_hook = mock_hook_cls.return_value
+        op = _make_operator(drop_on_completion=False)
+        op.execute(context=None)
+        mock_hook.run.assert_not_called()

Reply via email to