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

yuqi1129 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new d4cad80ee9 [#13323] test(client): Add Ray Data Iceberg REST contract 
tests (#13325)
d4cad80ee9 is described below

commit d4cad80ee938e59fc872dc77e002e275d92d72c1
Author: Thunderkeg <[email protected]>
AuthorDate: Tue Oct 27 09:35:04 2026 +0800

    [#13323] test(client): Add Ray Data Iceberg REST contract tests (#13325)
    
    ### What changes were proposed in this pull request?
    
    This pull request adds a Gravitino-owned Ray Data Iceberg REST
    integration test to the Python client test suite and runs it in a
    dedicated Python integration CI job.
    
    The change:
    - adds pinned Ray 2.55.1, PyIceberg 0.12.0, and pandas 2.3.3 test
    dependencies;
    - adds a dedicated `rayIcebergIT` Gradle task with an isolated Python
    environment;
    - generates the ignored `gravitino/version.ini` file before running the
    client test;
    - configures the Gravitino Iceberg REST auxiliary service with the
    dynamic catalog provider;
    - uses Gravitino's memory Iceberg catalog and PyIceberg to create a
    namespace and pre-existing table fixture;
    - verifies empty-table reads, Ray Data writes, subsequent reads, field
    order, row counts, and filtering;
    - adds setup-failure cleanup for Gravitino configuration, metadata, and
    temporary data;
    - runs `rayIcebergIT` in a dedicated Python 3.12 CI job when the
    existing Python integration source-change filter matches;
    - makes the dedicated task fail instead of skip when Ray or PyIceberg is
    unavailable.
    
    The implementation does not modify Java Iceberg REST code, Ray source
    code, or Ray `Catalog.resolve()` behavior.
    
    ### Why are the changes needed?
    
    Gravitino documents Ray Data access through the Iceberg REST endpoint
    and `catalog_kwargs`, but the Python client integration suite did not
    have a real end-to-end regression test for this contract.
    
    The test protects the existing-table read/write path and validates the
    Gravitino auxiliary service lifecycle using the existing Lance Ray IT as
    the implementation baseline; wiring the task into CI ensures the
    contract is exercised automatically.
    
    Fix: #13323
    
    ### Does this PR introduce _any_ user-facing change?
    
    No end-user API or runtime behavior changes are introduced;
    contributor-facing Python integration-test tooling is added.
    
    ### How was this patch tested?
    
    Passed:
    - `git diff --check`
    - `ruff check --no-cache
    clients/client-python/tests/integration/test_ray_iceberg.py`
    - `ruff format --check --no-cache
    clients/client-python/tests/integration/test_ray_iceberg.py`
    - `actionlint .github/workflows/python-integration-test.yml
    .github/workflows/ray-iceberg-it-action.yml`
    - Gradle task graph for `:clients:client-python:rayIcebergIT --dry-run`
    - `./gradlew --offline -PpythonVersion=3.12 -PskipDockerTests=true
    -PskipWeb=true :clients:client-python:rayIcebergIT --no-daemon
    --console=plain` — passed; 1 test ran, 0 skipped.
    
    ---------
    
    Signed-off-by: jiangxt2 <[email protected]>
---
 .github/workflows/python-integration-test.yml      |  11 +
 .github/workflows/ray-iceberg-it-action.yml        |  71 ++++++
 clients/client-python/build.gradle.kts             |  61 +++++
 clients/client-python/requirements-ray-iceberg.txt |  21 ++
 .../tests/integration/integration_test_env.py      |  16 +-
 .../tests/integration/test_ray_iceberg.py          | 265 +++++++++++++++++++++
 .../tests/unittests/test_integration_test_env.py   | 146 ++++++++++++
 7 files changed, 587 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/python-integration-test.yml 
b/.github/workflows/python-integration-test.yml
index 653ee4e8fb..4670c221ce 100644
--- a/.github/workflows/python-integration-test.yml
+++ b/.github/workflows/python-integration-test.yml
@@ -92,3 +92,14 @@ jobs:
       sync: ${{ inputs.sync }}
       architecture: ${{ matrix.architecture }}
       java-version: ${{ matrix.java-version }}
+
+  RayIcebergIT:
+    needs: changes
+    if: needs.changes.outputs.source_changes == 'true'
+    uses: ./.github/workflows/ray-iceberg-it-action.yml
+    with:
+      base-ref: ${{ inputs.base-ref }}
+      base-sha: ${{ inputs.base-sha }}
+      head-repository: ${{ inputs.head-repository }}
+      head-sha: ${{ inputs.head-sha }}
+      sync: ${{ inputs.sync }}
diff --git a/.github/workflows/ray-iceberg-it-action.yml 
b/.github/workflows/ray-iceberg-it-action.yml
new file mode 100644
index 0000000000..06278a576b
--- /dev/null
+++ b/.github/workflows/ray-iceberg-it-action.yml
@@ -0,0 +1,71 @@
+name: Ray Iceberg REST Contract Test Action
+
+on:
+  workflow_call:
+    inputs:
+      base-ref:
+        type: string
+        required: false
+        default: ''
+      base-sha:
+        type: string
+        required: false
+        default: ''
+      head-repository:
+        type: string
+        required: false
+        default: ''
+      head-sha:
+        type: string
+        required: false
+        default: ''
+      sync:
+        type: string
+        required: false
+        default: ''
+
+jobs:
+  RayIcebergIT:
+    name: Ray Iceberg REST contract (Python 3.12)
+    runs-on: ubuntu-latest
+    timeout-minutes: 120
+    steps:
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
+      - uses: ./.github/actions/checkout-and-sync
+        with:
+          base-ref: ${{ inputs.base-ref }}
+          base-sha: ${{ inputs.base-sha }}
+          head-repository: ${{ inputs.head-repository }}
+          head-sha: ${{ inputs.head-sha }}
+          sync: ${{ inputs.sync }}
+
+      - uses: actions/setup-java@v4
+        with:
+          java-version: 17
+          distribution: "temurin"
+          cache: "gradle"
+
+      - name: Free up disk space
+        run: dev/ci/util_free_space.sh
+
+      - name: Package Gravitino
+        run: ./gradlew compileDistribution -PskipWeb=true -x test
+
+      - name: Run Ray Iceberg REST contract test
+        id: rayIcebergIT
+        run: |
+          ./gradlew -PpythonVersion=3.12 -PskipDockerTests=true -PskipWeb=true 
\
+            :clients:client-python:rayIcebergIT
+
+      - name: Upload Ray Iceberg IT reports and logs
+        uses: actions/upload-artifact@v7
+        if: ${{ failure() && steps.rayIcebergIT.outcome == 'failure' }}
+        with:
+          name: ray-iceberg-it-reports
+          path: |
+            build/reports
+            integration-test/build/integration-test.log
+            distribution/package/logs/gravitino-server.out
+            distribution/package/logs/gravitino-server.log
diff --git a/clients/client-python/build.gradle.kts 
b/clients/client-python/build.gradle.kts
index 3311608c80..41de64f8dd 100644
--- a/clients/client-python/build.gradle.kts
+++ b/clients/client-python/build.gradle.kts
@@ -226,6 +226,67 @@ tasks {
     environment = envMap
   }
 
+  val rayIcebergPythonVersion = 
project.rootProject.extra["pythonVersion"].toString()
+  val rayIcebergOsDir = when {
+    org.gradle.internal.os.OperatingSystem.current().isMacOsX -> "MacOSX"
+    org.gradle.internal.os.OperatingSystem.current().isLinux -> "Linux"
+    else -> throw GradleException("Ray Iceberg IT only supports macOS and 
Linux")
+  }
+  val rayIcebergMiniforgeDir =
+    
project.file("${project.rootDir}/.gradle/python/$rayIcebergOsDir/Miniforge3")
+  val rayIcebergCondaExecutable = rayIcebergMiniforgeDir.resolve("bin/conda")
+  val rayIcebergEnvDir =
+    project.layout.buildDirectory.dir("ray-iceberg-it-env").get().asFile
+  val rayIcebergPythonExecutable = rayIcebergEnvDir.resolve("bin/python")
+
+  val rayIcebergDependencies by registering {
+    group = "verification"
+    description = "Install the dependencies for the Ray Iceberg IT."
+    dependsOn("miniforgeSetup")
+    doLast {
+      if (!rayIcebergPythonExecutable.exists()) {
+        project.exec {
+          executable = rayIcebergCondaExecutable.absolutePath
+          args = listOf(
+            "create", "--prefix", rayIcebergEnvDir.absolutePath,
+            "python=$rayIcebergPythonVersion", "--yes"
+          )
+        }
+      }
+      project.exec {
+        executable = rayIcebergPythonExecutable.absolutePath
+        workingDir = projectDir
+        args = listOf(
+          "-m", "pip", "install", "-e", ".", "-r", 
"requirements-ray-iceberg.txt"
+        )
+      }
+      project.exec {
+        executable = rayIcebergPythonExecutable.absolutePath
+        workingDir = projectDir
+        args = listOf("scripts/generate_version.py")
+      }
+    }
+  }
+
+  register("rayIcebergIT") {
+    group = "verification"
+    description = "Run the Gravitino Iceberg REST contract test with Ray Data."
+    dependsOn(rayIcebergDependencies, startGravitinoServer)
+    finalizedBy(stopGravitinoServer)
+    doLast {
+      project.exec {
+        executable = rayIcebergPythonExecutable.absolutePath
+        workingDir = projectDir.resolve("./tests/integration")
+        environment("PROJECT_VERSION", project.version)
+        environment("GRAVITINO_HOME", project.rootDir.path + 
"/distribution/package")
+        environment("START_EXTERNAL_GRAVITINO", "true")
+        environment("RAY_ICEBERG_IT_REQUIRED", "true")
+        environment("PYTHONPATH", 
"${project.rootDir.path}/clients/client-python")
+        args = listOf("-m", "unittest", "-v", "test_ray_iceberg")
+      }
+    }
+  }
+
   val unitCoverageReport by registering(VenvTask::class){
     venvExec = "coverage"
     args = listOf("html")
diff --git a/clients/client-python/requirements-ray-iceberg.txt 
b/clients/client-python/requirements-ray-iceberg.txt
new file mode 100644
index 0000000000..5d61395414
--- /dev/null
+++ b/clients/client-python/requirements-ray-iceberg.txt
@@ -0,0 +1,21 @@
+# 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.
+
+ray[data]==2.55.1
+pyiceberg==0.12.0
+pandas==2.3.3
+pyarrow==23.0.1
diff --git a/clients/client-python/tests/integration/integration_test_env.py 
b/clients/client-python/tests/integration/integration_test_env.py
index 06fa23588d..1fe656f1a1 100644
--- a/clients/client-python/tests/integration/integration_test_env.py
+++ b/clients/client-python/tests/integration/integration_test_env.py
@@ -259,17 +259,24 @@ class IntegrationTestEnv(unittest.TestCase):
                 "project root directory."
             )
 
+        env_vars = os.environ.copy()
+        env_vars["HADOOP_USER_NAME"] = "anonymous"
+        # Stop the server before removing data so it cannot recreate files 
during cleanup.
+        subprocess.run(
+            [gravitino_startup_script, "stop"],
+            env=env_vars,
+            check=True,
+        )
+
         # remove data dir under gravitino_home
         data_dir = os.path.join(gravitino_home, "data")
         if os.path.exists(data_dir):
             logger.info("Remove Gravitino data directory: %s", data_dir)
             shutil.rmtree(data_dir)
 
-        # Restart Gravitino Server
-        env_vars = os.environ.copy()
-        env_vars["HADOOP_USER_NAME"] = "anonymous"
+        # Start Gravitino Server
         result = subprocess.run(
-            [gravitino_startup_script, "restart"],
+            [gravitino_startup_script, "start"],
             env=env_vars,
             capture_output=True,
             text=True,
@@ -279,6 +286,7 @@ class IntegrationTestEnv(unittest.TestCase):
             logger.info("stdout: %s", result.stdout)
         if result.stderr:
             logger.info("stderr: %s", result.stderr)
+        result.check_returncode()
 
         success = check_gravitino_server_status()
         if success:
diff --git a/clients/client-python/tests/integration/test_ray_iceberg.py 
b/clients/client-python/tests/integration/test_ray_iceberg.py
new file mode 100644
index 0000000000..3e79373edf
--- /dev/null
+++ b/clients/client-python/tests/integration/test_ray_iceberg.py
@@ -0,0 +1,265 @@
+# 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.
+
+import logging
+import os
+import shutil
+import tempfile
+import time
+import unittest
+from typing import Optional
+from uuid import uuid4
+
+import requests
+
+from gravitino import Catalog, GravitinoAdminClient, GravitinoClient
+from tests.integration.integration_test_env import IntegrationTestEnv
+
+logger = logging.getLogger(__name__)
+
+ICEBERG_REST_BASE_URL = "http://localhost:9001/iceberg/";
+MAIN_CONF_FILE = "conf/gravitino.conf"
+ICEBERG_REST_CONFIG_PROVIDER_KEY = 
"gravitino.iceberg-rest.catalog-config-provider"
+ICEBERG_REST_METALAKE_KEY = "gravitino.iceberg-rest.gravitino-metalake"
+ICEBERG_REST_DEFAULT_CATALOG_KEY = 
"gravitino.iceberg-rest.default-catalog-name"
+
+
+def _missing_ray_iceberg_dependencies() -> Optional[str]:
+    missing = []
+    for module in ("ray", "pyiceberg"):
+        try:
+            __import__(module)
+        except ImportError:
+            missing.append(module)
+    return ", ".join(missing) if missing else None
+
+
+_MISSING_RAY_ICEBERG_DEPS = _missing_ray_iceberg_dependencies()
+_RAY_ICEBERG_IT_REQUIRED = os.environ.get("RAY_ICEBERG_IT_REQUIRED") == "true"
+
+
[email protected](
+    _MISSING_RAY_ICEBERG_DEPS is not None and not _RAY_ICEBERG_IT_REQUIRED,
+    f"Ray Iceberg test deps not installed: {_MISSING_RAY_ICEBERG_DEPS}. "
+    "Install with: pip install -r 
clients/client-python/requirements-ray-iceberg.txt",
+)
+class TestRayIcebergIntegration(IntegrationTestEnv):
+    """Exercise Ray Data's Iceberg REST read/write contract with Gravitino."""
+
+    METALAKE_NAME = f"ray_iceberg_it_{uuid4().hex[:8]}"
+    CATALOG_NAME = f"ray_iceberg_{uuid4().hex[:8]}"
+    SCHEMA_NAME = "ray_schema"
+    TABLE_NAME = "ray_table"
+    TABLE_IDENTIFIER = f"{SCHEMA_NAME}.{TABLE_NAME}"
+
+    gravitino_admin_client: Optional[GravitinoAdminClient] = None
+    gravitino_client: Optional[GravitinoClient] = None
+    iceberg_catalog = None
+    temp_dir: Optional[str] = None
+    main_conf_path: Optional[str] = None
+    appended_iceberg_rest_conf = False
+    setup_completed = False
+
+    @classmethod
+    def setUpClass(cls):
+        cls.addClassCleanup(cls._cleanup_after_setup_failure)
+        super().setUpClass()
+
+        cls._get_gravitino_home()
+        cls.main_conf_path = os.path.join(cls.gravitino_home, MAIN_CONF_FILE)
+        cls._append_conf(
+            {
+                ICEBERG_REST_CONFIG_PROVIDER_KEY: "dynamic-config-provider",
+                ICEBERG_REST_METALAKE_KEY: cls.METALAKE_NAME,
+                ICEBERG_REST_DEFAULT_CATALOG_KEY: cls.CATALOG_NAME,
+            },
+            cls.main_conf_path,
+        )
+        cls.appended_iceberg_rest_conf = True
+        cls.restart_server()
+
+        cls.gravitino_admin_client = 
GravitinoAdminClient("http://localhost:8090";)
+        cls.gravitino_admin_client.create_metalake(
+            cls.METALAKE_NAME,
+            comment="Ray Iceberg IT metalake",
+            properties={},
+        )
+        cls.gravitino_client = GravitinoClient(
+            uri="http://localhost:8090";, metalake_name=cls.METALAKE_NAME
+        )
+        cls.temp_dir = tempfile.mkdtemp(prefix="ray_iceberg_it_")
+        cls.gravitino_client.create_catalog(
+            name=cls.CATALOG_NAME,
+            catalog_type=Catalog.Type.RELATIONAL,
+            provider="lakehouse-iceberg",
+            comment="Ray Iceberg IT catalog",
+            # Gravitino requires a non-empty URI property even though its 
memory backend
+            # does not use the URI to initialize the in-memory Iceberg catalog.
+            properties={
+                "catalog-backend": "memory",
+                "uri": "memory://ray-iceberg-it",
+                "warehouse": cls.temp_dir,
+            },
+        )
+
+        if not cls._wait_for_iceberg_rest_ready():
+            raise RuntimeError(
+                "Iceberg REST auxiliary service did not become ready at "
+                + ICEBERG_REST_BASE_URL
+            )
+
+        # Imports are deferred so the module can be collected without the 
optional IT deps.
+        # pylint: disable=import-error,import-outside-toplevel
+        from pyiceberg.catalog import load_catalog
+        from pyiceberg.schema import Schema
+        from pyiceberg.types import LongType, NestedField, StringType
+
+        # pylint: enable=import-outside-toplevel
+        cls.iceberg_catalog = load_catalog(**cls._catalog_kwargs())
+        cls.iceberg_catalog.create_namespace(cls.SCHEMA_NAME)
+        cls.iceberg_catalog.create_table(
+            cls.TABLE_IDENTIFIER,
+            schema=Schema(
+                NestedField(1, "id", LongType(), required=False),
+                NestedField(2, "value", StringType(), required=False),
+            ),
+        )
+        cls.setup_completed = True
+
+    @classmethod
+    def tearDownClass(cls):
+        try:
+            cls._cleanup_resources()
+        finally:
+            if cls.use_external_gravitino() or cls.gravitino_startup_script is 
not None:
+                super().tearDownClass()
+
+    @classmethod
+    def _cleanup_after_setup_failure(cls):
+        if cls.setup_completed:
+            return
+        try:
+            cls._cleanup_resources()
+        finally:
+            if cls.use_external_gravitino() or cls.gravitino_startup_script is 
not None:
+                super().tearDownClass()
+
+    @classmethod
+    def _cleanup_resources(cls):
+        failures = []
+
+        try:
+            if cls.iceberg_catalog is not None:
+                cls.iceberg_catalog.drop_table(cls.TABLE_IDENTIFIER)
+        except Exception as error:  # pylint: disable=broad-exception-caught
+            failures.append(("drop Iceberg table", error))
+
+        try:
+            if cls.gravitino_client is not None:
+                cls.gravitino_client.drop_catalog(name=cls.CATALOG_NAME, 
force=True)
+        except Exception as error:  # pylint: disable=broad-exception-caught
+            failures.append(("drop Gravitino catalog", error))
+
+        try:
+            if cls.gravitino_admin_client is not None:
+                cls.gravitino_admin_client.drop_metalake(
+                    name=cls.METALAKE_NAME, force=True
+                )
+        except Exception as error:  # pylint: disable=broad-exception-caught
+            failures.append(("drop Gravitino metalake", error))
+
+        try:
+            if cls.appended_iceberg_rest_conf and cls.main_conf_path is not 
None:
+                cls._reset_conf(
+                    {
+                        ICEBERG_REST_CONFIG_PROVIDER_KEY: 
"dynamic-config-provider",
+                        ICEBERG_REST_METALAKE_KEY: cls.METALAKE_NAME,
+                        ICEBERG_REST_DEFAULT_CATALOG_KEY: cls.CATALOG_NAME,
+                    },
+                    cls.main_conf_path,
+                )
+                cls.restart_server()
+        except Exception as error:  # pylint: disable=broad-exception-caught
+            failures.append(("reset Iceberg REST conf", error))
+        finally:
+            if cls.temp_dir and os.path.exists(cls.temp_dir):
+                shutil.rmtree(cls.temp_dir, ignore_errors=True)
+
+        for step, error in failures:
+            logger.warning("Cleanup step %s failed: %s", step, error)
+
+    def test_ray_can_write_and_read_existing_iceberg_table(self):
+        # pylint: disable=import-outside-toplevel,import-error
+        import ray
+
+        # pylint: enable=import-outside-toplevel,import-error
+        ray.init(
+            ignore_reinit_error=True,
+            num_cpus=2,
+            include_dashboard=False,
+            log_to_driver=False,
+        )
+        try:
+            catalog_kwargs = self._catalog_kwargs()
+            empty_dataset = ray.data.read_iceberg(
+                table_identifier=self.TABLE_IDENTIFIER,
+                catalog_kwargs=catalog_kwargs,
+            )
+            self.assertEqual(0, empty_dataset.count())
+
+            data = ray.data.from_items(
+                [{"id": index, "value": f"value-{index}"} for index in 
range(8)]
+            ).repartition(2)
+            data.write_iceberg(
+                table_identifier=self.TABLE_IDENTIFIER,
+                catalog_kwargs=catalog_kwargs,
+            )
+
+            dataset = ray.data.read_iceberg(
+                table_identifier=self.TABLE_IDENTIFIER,
+                catalog_kwargs=catalog_kwargs,
+            )
+            self.assertEqual(8, dataset.count())
+            self.assertEqual(["id", "value"], dataset.schema().names)
+            self.assertEqual(4, dataset.filter(lambda row: row["id"] % 2 == 
0).count())
+        finally:
+            ray.shutdown()
+
+    @classmethod
+    def _catalog_kwargs(cls):
+        return {
+            "name": "default",
+            "type": "rest",
+            "uri": ICEBERG_REST_BASE_URL,
+            "auth": {
+                "type": "basic",
+                "basic": {"username": "anonymous", "password": ""},
+            },
+        }
+
+    @staticmethod
+    def _wait_for_iceberg_rest_ready(timeout_s: float = 60.0) -> bool:
+        deadline = time.monotonic() + timeout_s
+        while time.monotonic() < deadline:
+            try:
+                response = requests.get(ICEBERG_REST_BASE_URL + "v1/config", 
timeout=2)
+                if response.status_code == 200:
+                    return True
+            except requests.RequestException:
+                pass
+            time.sleep(0.5)
+        return False
diff --git a/clients/client-python/tests/unittests/test_integration_test_env.py 
b/clients/client-python/tests/unittests/test_integration_test_env.py
new file mode 100644
index 0000000000..c2ac040014
--- /dev/null
+++ b/clients/client-python/tests/unittests/test_integration_test_env.py
@@ -0,0 +1,146 @@
+# 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.
+
+import os
+import shutil
+import subprocess
+import tempfile
+import unittest
+from contextlib import ExitStack
+from pathlib import Path
+from unittest.mock import patch
+
+from gravitino.exceptions.base import GravitinoRuntimeException
+from tests.integration.integration_test_env import IntegrationTestEnv
+
+
+class TestIntegrationTestEnv(unittest.TestCase):
+    def setUp(self):
+        self.patches = ExitStack()
+        self.addCleanup(self.patches.close)
+        home = Path(self.patches.enter_context(tempfile.TemporaryDirectory()))
+        self.script = home / "bin" / "gravitino.sh"
+        self.script.parent.mkdir()
+        self.script.touch()
+        self.data = home / "data"
+        self.data.mkdir()
+        self.metadata = self.data / "metadata"
+        self.metadata.write_text("existing metadata", encoding="utf-8")
+
+        self.patches.enter_context(
+            patch.dict(os.environ, {"GRAVITINO_HOME": str(home)})
+        )
+        self.run = self.patches.enter_context(
+            patch("tests.integration.integration_test_env.subprocess.run")
+        )
+        self.run.return_value = subprocess.CompletedProcess([], 0, "", "")
+        self.ready = self.patches.enter_context(
+            patch(
+                
"tests.integration.integration_test_env.check_gravitino_server_status",
+                return_value=True,
+            )
+        )
+        self.client = self.patches.enter_context(
+            
patch("tests.integration.integration_test_env.GravitinoAdminClient")
+        )
+        self.patches.enter_context(
+            patch.object(IntegrationTestEnv, "gravitino_admin_client", None)
+        )
+
+    def test_restart_stops_server_before_removing_data(self):
+        events = []
+        remove_data = shutil.rmtree
+
+        def run_command(command, **kwargs):
+            events.append(command[1])
+            self.assertEqual("anonymous", kwargs["env"]["HADOOP_USER_NAME"])
+            return subprocess.CompletedProcess(command, 0, "", "")
+
+        def remove_directory(path):
+            self.assertEqual(["stop"], events)
+            events.append("remove data")
+            remove_data(path)
+
+        self.run.side_effect = run_command
+        self.ready.side_effect = lambda: events.append("ready") or True
+        with patch(
+            "tests.integration.integration_test_env.shutil.rmtree",
+            side_effect=remove_directory,
+        ):
+            IntegrationTestEnv.restart_server()
+
+        self.assertEqual(["stop", "remove data", "start", "ready"], events)
+        self.assertFalse(self.data.exists())
+        self.assertIs(
+            self.client.return_value, IntegrationTestEnv.gravitino_admin_client
+        )
+
+    def test_restart_without_data_directory(self):
+        shutil.rmtree(self.data)
+        with patch("tests.integration.integration_test_env.shutil.rmtree") as 
remove:
+            IntegrationTestEnv.restart_server()
+        remove.assert_not_called()
+        self.assertEqual(
+            ["stop", "start"], [call.args[0][1] for call in 
self.run.call_args_list]
+        )
+        self.ready.assert_called_once_with()
+
+    def test_failed_stop_preserves_data_and_does_not_start(self):
+        self.run.side_effect = subprocess.CalledProcessError(
+            1, [str(self.script), "stop"]
+        )
+        with self.assertRaises(subprocess.CalledProcessError):
+            IntegrationTestEnv.restart_server()
+        self.assertEqual("existing metadata", 
self.metadata.read_text(encoding="utf-8"))
+        self.assertEqual(1, self.run.call_count)
+        self.ready.assert_not_called()
+        self.client.assert_not_called()
+
+    def test_failed_cleanup_does_not_start(self):
+        with patch(
+            "tests.integration.integration_test_env.shutil.rmtree",
+            side_effect=OSError("Cannot remove test metadata"),
+        ):
+            with self.assertRaises(OSError):
+                IntegrationTestEnv.restart_server()
+        self.assertEqual(1, self.run.call_count)
+        self.assertEqual("stop", self.run.call_args.args[0][1])
+        self.ready.assert_not_called()
+
+    def test_failed_start_does_not_poll_readiness(self):
+        self.run.side_effect = [
+            subprocess.CompletedProcess([str(self.script), "stop"], 0, "", ""),
+            subprocess.CompletedProcess([str(self.script), "start"], 1, "", 
"failed"),
+        ]
+        with self.assertRaises(subprocess.CalledProcessError):
+            IntegrationTestEnv.restart_server()
+        self.assertFalse(self.data.exists())
+        self.ready.assert_not_called()
+        self.client.assert_not_called()
+
+    def test_unready_server_does_not_create_client(self):
+        self.ready.return_value = False
+        with self.assertRaises(GravitinoRuntimeException):
+            IntegrationTestEnv.restart_server()
+        self.client.assert_not_called()
+
+    def test_missing_script_preserves_data(self):
+        self.script.unlink()
+        with self.assertRaises(GravitinoRuntimeException):
+            IntegrationTestEnv.restart_server()
+        self.assertTrue(self.metadata.exists())
+        self.run.assert_not_called()

Reply via email to