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()