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

roryqi 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 f17e5755c3 [#8616] Improvement(client-python): Add 
MetadataObjectRoleOperations (#10761)
f17e5755c3 is described below

commit f17e5755c3de824cca6717015ba51099fdb182a1
Author: Lord of Abyss <[email protected]>
AuthorDate: Fri Apr 17 11:08:23 2026 +0800

    [#8616] Improvement(client-python): Add MetadataObjectRoleOperations 
(#10761)
    
    ### What changes were proposed in this pull request?
    
    Add metadata object role support to the Python client by introducing
    `MetadataObjectRoleOperations`, `RoleNamesListResponse`, and
    `RoleErrorHandler`.
    
    This PR also adds unit tests for:
    - listing bound role names for a metadata object
    - role response serialization and validation
    - role error handler exception mapping
    
    ### Why are the changes needed?
    
    The Python client currently lacks support for listing role names bound
    to a metadata object, while the corresponding server-side API already
    exists.
    
    This change fills that gap so Python client users can consume metadata
    object role APIs consistently and get proper client-side exception
    mapping for role-related failures.
    
    Fix: #8616
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes.
    
    It adds Python client support for listing role names bound to a metadata
    object through the new metadata object role operations.
    
    No property keys are added or removed.
    
    ### How was this patch tested?
    
    Added unit tests covering:
    - request path generation for metadata object role operations
    - successful role name listing
    - `RoleNamesListResponse` serialization and validation
    - `RoleErrorHandler` mappings for representative `ILLEGAL_ARGUMENTS`,
    `NOT_FOUND`, and `ALREADY_EXISTS` cases
    
    Verified with:
    1. `cd clients/client-python && PYTHONPATH=$PWD python -m unittest
    tests.unittests.client.test_metadata_object_role_operations -v`
    2. `./gradlew :clients:client-python:unitTests --console=plain`
---
 .../client/metadata_object_role_operations.py      |  62 +++++++++++
 .../gravitino/dto/responses/name_list_response.py  |  43 ++++++++
 clients/client-python/gravitino/exceptions/base.py |  20 ++++
 .../exceptions/handlers/role_error_handler.py      |  83 ++++++++++++++
 .../client/test_metadata_object_role_operations.py | 121 +++++++++++++++++++++
 .../unittests/dto/responses/test_responses.py      |  35 +++++-
 6 files changed, 362 insertions(+), 2 deletions(-)

diff --git 
a/clients/client-python/gravitino/client/metadata_object_role_operations.py 
b/clients/client-python/gravitino/client/metadata_object_role_operations.py
new file mode 100644
index 0000000000..e4b4255ced
--- /dev/null
+++ b/clients/client-python/gravitino/client/metadata_object_role_operations.py
@@ -0,0 +1,62 @@
+# 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 gravitino.api.authorization.supports_roles import SupportsRoles
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.dto.responses.name_list_response import NameListResponse
+from gravitino.exceptions.handlers.role_error_handler import (
+    ROLE_ERROR_HANDLER,
+)
+from gravitino.rest.rest_utils import encode_string
+from gravitino.utils.http_client import HTTPClient
+
+
+class MetadataObjectRoleOperations(SupportsRoles):
+    """
+    Represents a response for a list of entity names.
+    """
+
+    def __init__(
+        self,
+        metalake_name: str,
+        metadata_object: MetadataObject,
+        rest_client: HTTPClient,
+    ) -> None:
+        super().__init__()
+        self.metalake_name = metalake_name
+        self.metadata_object = metadata_object
+        self.rest_client = rest_client
+        self.role_request_path = (
+            "api/metalakes"
+            + f"/{encode_string(metalake_name)}/"
+            + "objects"
+            + f"/{metadata_object.type().name.lower()}"
+            + f"/{encode_string(metadata_object.full_name())}"
+            + "/roles"
+        )
+
+    def list_binding_role_names(self) -> list[str]:
+        response = self.rest_client.get(
+            self.role_request_path, params={}, error_handler=ROLE_ERROR_HANDLER
+        )
+        role_names_list_resp = NameListResponse.from_json(
+            response.body, infer_missing=True
+        )
+        role_names_list_resp.validate()
+        return role_names_list_resp.names
diff --git 
a/clients/client-python/gravitino/dto/responses/name_list_response.py 
b/clients/client-python/gravitino/dto/responses/name_list_response.py
new file mode 100644
index 0000000000..eb7a099296
--- /dev/null
+++ b/clients/client-python/gravitino/dto/responses/name_list_response.py
@@ -0,0 +1,43 @@
+# 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 dataclasses import dataclass, field
+
+from dataclasses_json import config, dataclass_json
+
+from gravitino.dto.responses.base_response import BaseResponse
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass_json
+@dataclass
+class NameListResponse(BaseResponse):
+    """Represents a response for a list of entity names."""
+
+    _names: list[str] = field(default_factory=list, 
metadata=config(field_name="names"))
+
+    @property
+    def names(self) -> list[str]:
+        return self._names
+
+    def validate(self):
+        Precondition.check_argument(self._names is not None, '"names" must not 
be null')
+
+        for role_name in self._names:
+            Precondition.check_string_not_empty(role_name, "name must not be 
null")
diff --git a/clients/client-python/gravitino/exceptions/base.py 
b/clients/client-python/gravitino/exceptions/base.py
index 6a60eb1c6a..499a5d19a6 100644
--- a/clients/client-python/gravitino/exceptions/base.py
+++ b/clients/client-python/gravitino/exceptions/base.py
@@ -227,3 +227,23 @@ class NoSuchFunctionException(NotFoundException):
 
 class FunctionAlreadyExistsException(AlreadyExistsException):
     """An exception thrown when a function already exists."""
+
+
+class IllegalPrivilegeException(IllegalArgumentException):
+    """An exception thrown when a privilege is invalid."""
+
+
+class IllegalMetadataObjectException(IllegalArgumentException):
+    """An exception thrown when a metadata object is invalid."""
+
+
+class NoSuchRoleException(NotFoundException):
+    """Exception thrown when a role with specified name is not existed."""
+
+
+class NoSuchMetadataObjectException(NotFoundException):
+    """Exception thrown when a metadata object with specified name doesn't 
exist."""
+
+
+class RoleAlreadyExistsException(AlreadyExistsException):
+    """Exception thrown when a role with specified name already exists."""
diff --git 
a/clients/client-python/gravitino/exceptions/handlers/role_error_handler.py 
b/clients/client-python/gravitino/exceptions/handlers/role_error_handler.py
new file mode 100644
index 0000000000..e10837d6d2
--- /dev/null
+++ b/clients/client-python/gravitino/exceptions/handlers/role_error_handler.py
@@ -0,0 +1,83 @@
+# 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 gravitino.constants.error import ErrorConstants
+from gravitino.dto.responses.error_response import ErrorResponse
+from gravitino.exceptions.base import (
+    ForbiddenException,
+    IllegalArgumentException,
+    IllegalMetadataObjectException,
+    IllegalPrivilegeException,
+    MetalakeNotInUseException,
+    NoSuchMetadataObjectException,
+    NoSuchMetalakeException,
+    NoSuchRoleException,
+    NotFoundException,
+    RoleAlreadyExistsException,
+    UnsupportedOperationException,
+)
+from gravitino.exceptions.handlers.rest_error_handler import RestErrorHandler
+
+
+class RoleErrorHandler(RestErrorHandler):
+    """Error handler specific to Role operations."""
+
+    def handle(self, error_response: ErrorResponse):
+        error_message = error_response.format_error_message()
+        code = error_response.code()
+        exception_type = error_response.type()
+
+        if code == ErrorConstants.ILLEGAL_ARGUMENTS_CODE:
+            if exception_type == IllegalPrivilegeException.__name__:
+                raise IllegalPrivilegeException(error_message)
+            if exception_type == IllegalMetadataObjectException.__name__:
+                raise IllegalMetadataObjectException(error_message)
+
+            raise IllegalArgumentException(error_message)
+
+        if code == ErrorConstants.NOT_FOUND_CODE:
+            if exception_type == NoSuchMetalakeException.__name__:
+                raise NoSuchMetalakeException(error_message)
+
+            if exception_type == NoSuchRoleException.__name__:
+                raise NoSuchRoleException(error_message)
+
+            if exception_type == NoSuchMetadataObjectException.__name__:
+                raise NoSuchMetadataObjectException(error_message)
+
+            raise NotFoundException(error_message)
+
+        if code == ErrorConstants.ALREADY_EXISTS_CODE:
+            raise RoleAlreadyExistsException(error_message)
+
+        if code == ErrorConstants.UNSUPPORTED_OPERATION_CODE:
+            raise UnsupportedOperationException(error_message)
+
+        if code == ErrorConstants.FORBIDDEN_CODE:
+            raise ForbiddenException(error_message)
+
+        if code == ErrorConstants.NOT_IN_USE_CODE:
+            raise MetalakeNotInUseException(error_message)
+
+        if code == ErrorConstants.INTERNAL_ERROR_CODE:
+            raise RuntimeError(error_message)
+
+        super().handle(error_response)
+
+
+ROLE_ERROR_HANDLER = RoleErrorHandler()
diff --git 
a/clients/client-python/tests/unittests/client/test_metadata_object_role_operations.py
 
b/clients/client-python/tests/unittests/client/test_metadata_object_role_operations.py
new file mode 100644
index 0000000000..7f4e1eaf09
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/client/test_metadata_object_role_operations.py
@@ -0,0 +1,121 @@
+# 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 unittest
+from unittest.mock import patch
+
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.metadata_objects import MetadataObjects
+from gravitino.client.metadata_object_role_operations import (
+    MetadataObjectRoleOperations,
+)
+from gravitino.dto.responses.error_response import ErrorResponse
+from gravitino.dto.responses.name_list_response import NameListResponse
+from gravitino.exceptions.base import (
+    IllegalMetadataObjectException,
+    IllegalPrivilegeException,
+    NoSuchMetadataObjectException,
+    NoSuchMetalakeException,
+    NoSuchRoleException,
+    RoleAlreadyExistsException,
+)
+from gravitino.exceptions.handlers.role_error_handler import ROLE_ERROR_HANDLER
+from gravitino.utils import HTTPClient
+from tests.unittests import mock_base
+
+
+class TestMetadataObjectRoleOperations(unittest.TestCase):
+    REST_CLIENT = HTTPClient("http://localhost:8090";)
+    METALAKE_NAME = "demo_metalake"
+
+    def test_list_binding_role_names(self) -> None:
+        expected_role_names_lst = ["role1", "role2"]
+        metadata_object = MetadataObjects.of(
+            ["catalog", "schema", "table"], MetadataObject.Type.TABLE
+        )
+        role_operations = MetadataObjectRoleOperations(
+            TestMetadataObjectRoleOperations.METALAKE_NAME,
+            metadata_object,
+            TestMetadataObjectRoleOperations.REST_CLIENT,
+        )
+        role_names_list_resp = NameListResponse(
+            0,
+            expected_role_names_lst,
+        )
+        json_str = role_names_list_resp.to_json()
+        mock_resp = mock_base.mock_http_response(json_str)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=mock_resp,
+        ) as mock_get:
+            retrieved_roles = role_operations.list_binding_role_names()
+            self.assertEqual(expected_role_names_lst, retrieved_roles)
+            mock_get.assert_called_once_with(
+                
"api/metalakes/demo_metalake/objects/table/catalog.schema.table/roles",
+                params={},
+                error_handler=ROLE_ERROR_HANDLER,
+            )
+
+    def test_role_error_handler(self) -> None:
+        with self.assertRaises(IllegalPrivilegeException):
+            ROLE_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    IllegalPrivilegeException,  # type: ignore
+                    "mock error",
+                )
+            )
+
+        with self.assertRaises(IllegalMetadataObjectException):
+            ROLE_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    IllegalMetadataObjectException,  # type: ignore
+                    "mock error",
+                )
+            )
+
+        with self.assertRaises(NoSuchMetalakeException):
+            ROLE_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    NoSuchMetalakeException,  # type: ignore
+                    "mock error",
+                )
+            )
+
+        with self.assertRaises(NoSuchRoleException):
+            ROLE_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    NoSuchRoleException,  # type: ignore
+                    "mock error",
+                )
+            )
+
+        with self.assertRaises(NoSuchMetadataObjectException):
+            ROLE_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    NoSuchMetadataObjectException,  # type: ignore
+                    "mock error",
+                )
+            )
+
+        with self.assertRaises(RoleAlreadyExistsException):
+            ROLE_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    RoleAlreadyExistsException,  # type: ignore
+                    "mock error",
+                )
+            )
diff --git 
a/clients/client-python/tests/unittests/dto/responses/test_responses.py 
b/clients/client-python/tests/unittests/dto/responses/test_responses.py
index 4b6930b69c..0971bf8033 100644
--- a/clients/client-python/tests/unittests/dto/responses/test_responses.py
+++ b/clients/client-python/tests/unittests/dto/responses/test_responses.py
@@ -25,12 +25,13 @@ from 
gravitino.dto.rel.partitions.json_serdes.partition_dto_serdes import (
 )
 from gravitino.dto.responses.credential_response import CredentialResponse
 from gravitino.dto.responses.file_location_response import FileLocationResponse
+from gravitino.dto.responses.function_list_response import FunctionListResponse
+from gravitino.dto.responses.function_response import FunctionResponse
 from gravitino.dto.responses.model_response import ModelResponse
 from gravitino.dto.responses.model_version_list_response import 
ModelVersionListResponse
 from gravitino.dto.responses.model_version_response import ModelVersionResponse
 from gravitino.dto.responses.model_version_uri_response import 
ModelVersionUriResponse
-from gravitino.dto.responses.function_list_response import FunctionListResponse
-from gravitino.dto.responses.function_response import FunctionResponse
+from gravitino.dto.responses.name_list_response import NameListResponse
 from gravitino.dto.responses.partition_list_response import 
PartitionListResponse
 from gravitino.dto.responses.partition_name_list_response import (
     PartitionNameListResponse,
@@ -579,3 +580,33 @@ class TestResponses(unittest.TestCase):
         resp.validate()
         self.assertEqual(1, len(resp.functions()))
         self.assertEqual("func1", resp.functions()[0].name())
+
+    def test_role_names_list_response(self) -> None:
+        role_response = NameListResponse(0, ["role1", "role2", "role3"])
+        role_response.validate()
+        json_str = _json.dumps(
+            {
+                "code": 0,
+                "names": ["role1", "role2", "role3"],
+            }
+        )
+
+        ser_json = _json.dumps(role_response.to_dict())
+        self.assertEqual(json_str, ser_json)
+        deser_dict = _json.loads(ser_json)
+
+        self.assertEqual(0, deser_dict.get("code"))
+        self.assertIsNotNone(deser_dict.get("names"))
+        self.assertEqual(3, len(deser_dict.get("names")))
+        self.assertEqual("role1", deser_dict["names"][0])
+        self.assertEqual("role2", deser_dict["names"][1])
+        self.assertEqual("role3", deser_dict["names"][2])
+
+    def test_role_names_list_response_validation(self) -> None:
+        with self.assertRaises(IllegalArgumentException):
+            role_response = NameListResponse(0, None)  # type: ignore
+            role_response.validate()
+
+        with self.assertRaises(IllegalArgumentException):
+            role_response = NameListResponse(0, ["role1", ""])
+            role_response.validate()

Reply via email to