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

jerryshao 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 67c867c73d [#11094] feat(client-python): add Group authorization 
management (#11109)
67c867c73d is described below

commit 67c867c73db0078cdaec3d55afd8f8cbcdd4edb6
Author: Sun Yuhan <[email protected]>
AuthorDate: Fri May 22 16:46:25 2026 +0800

    [#11094] feat(client-python): add Group authorization management (#11109)
    
    ## Summary
    This PR adds Group authorization management support to the Gravitino
    Python client SDK, following the pattern established in #11058 (User
    management). It is split from the original monolithic PR #10783.
    
    ### Changes
    - **API**: `Group` interface with `name()` and `roles()` methods
    - **DTO**: `GroupDTO` with immutable roles (`tuple[str, ...]`
    internally, defensive `list` copy via `roles()`); audit null validation
    in Builder
    - **Request/Response**: `GroupAddRequest`, `GroupResponse`,
    `GroupListResponse`, `GroupNamesListResponse`
    - **Error Handler**: `GroupErrorHandler` mapping REST error codes to
    `NoSuchGroupException`, `GroupAlreadyExistsException`, etc.
    - **Client**: 5 CRUD methods on both `GravitinoMetalake` and
    `GravitinoClient` (`add_group`, `remove_group`, `get_group`,
    `list_groups`, `list_group_names`)
    - **Tests**: Unit tests (33 cases) and integration tests (6 cases)
    
    ### Sub-task of
    - #10782 (Authorization management for Python SDK)
    
    ## Related issues
    - Closes #11094
    
    ## Reviewers
    @jerryshao
    
    ---------
    
    Co-authored-by: Sun Yuhan <[email protected]>
---
 .../gravitino/api/authorization/__init__.py        |   2 +
 .../api/authorization/{__init__.py => group.py}    |  39 ++-
 .../gravitino/client/gravitino_client.py           |  69 ++++++
 .../gravitino/client/gravitino_metalake.py         | 119 +++++++++
 .../gravitino/dto/authorization/group_dto.py       |  91 +++++++
 .../requests/group_add_request.py}                 |  30 ++-
 .../gravitino/dto/responses/group_response.py      |  79 ++++++
 clients/client-python/gravitino/exceptions/base.py |   8 +
 .../exceptions/handlers/group_error_handler.py     |  67 ++++++
 .../tests/integration/test_group_management.py     | 124 ++++++++++
 .../client-python/tests/integration/test_owner.py  |  32 +--
 .../client-python/tests/integration/test_user.py   |  15 +-
 .../client/test_metalake_group_operations.py       | 268 +++++++++++++++++++++
 .../dto/requests/test_group_add_request.py}        |  30 ++-
 .../unittests/dto/responses/test_group_response.py |  76 ++++++
 .../tests/unittests/dto/test_group_dto.py          | 126 ++++++++++
 .../tests/unittests/test_error_handler.py          |  59 +++++
 17 files changed, 1174 insertions(+), 60 deletions(-)

diff --git a/clients/client-python/gravitino/api/authorization/__init__.py 
b/clients/client-python/gravitino/api/authorization/__init__.py
index f1b0f5de98..b8420e8915 100644
--- a/clients/client-python/gravitino/api/authorization/__init__.py
+++ b/clients/client-python/gravitino/api/authorization/__init__.py
@@ -17,12 +17,14 @@
 
 from __future__ import annotations
 
+from gravitino.api.authorization.group import Group
 from gravitino.api.authorization.privileges import Privileges
 from gravitino.api.authorization.role import Role
 from gravitino.api.authorization.securable_objects import SecurableObjects
 from gravitino.api.authorization.user import User
 
 __all__ = [
+    "Group",
     "Role",
     "SecurableObjects",
     "Privileges",
diff --git a/clients/client-python/gravitino/api/authorization/__init__.py 
b/clients/client-python/gravitino/api/authorization/group.py
similarity index 54%
copy from clients/client-python/gravitino/api/authorization/__init__.py
copy to clients/client-python/gravitino/api/authorization/group.py
index f1b0f5de98..ba5a017d0b 100644
--- a/clients/client-python/gravitino/api/authorization/__init__.py
+++ b/clients/client-python/gravitino/api/authorization/group.py
@@ -17,14 +17,31 @@
 
 from __future__ import annotations
 
-from gravitino.api.authorization.privileges import Privileges
-from gravitino.api.authorization.role import Role
-from gravitino.api.authorization.securable_objects import SecurableObjects
-from gravitino.api.authorization.user import User
-
-__all__ = [
-    "Role",
-    "SecurableObjects",
-    "Privileges",
-    "User",
-]
+from abc import abstractmethod
+
+from gravitino.api.auditable import Auditable
+
+
+class Group(Auditable):
+    """The interface of a group. The group is a collection of users in the 
authorization system."""
+
+    @abstractmethod
+    def name(self) -> str:
+        """
+        The name of the group.
+
+        Returns:
+            str: The name of the group.
+        """
+        raise NotImplementedError()
+
+    @abstractmethod
+    def roles(self) -> list[str]:
+        """
+        The roles of the group. A group can have multiple roles.
+        Every role binds several privileges.
+
+        Returns:
+            list[str]: The role names of the group.
+        """
+        raise NotImplementedError()
diff --git a/clients/client-python/gravitino/client/gravitino_client.py 
b/clients/client-python/gravitino/client/gravitino_client.py
index 1503e26506..f61185d36c 100644
--- a/clients/client-python/gravitino/client/gravitino_client.py
+++ b/clients/client-python/gravitino/client/gravitino_client.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 
 from typing import Dict, List, Optional
 
+from gravitino.api.authorization.group import Group
 from gravitino.api.authorization.owner import Owner
 from gravitino.api.authorization.user import User
 from gravitino.api.catalog import Catalog
@@ -438,3 +439,71 @@ class GravitinoClient(GravitinoClientBase, SupportsJobs, 
TagOperations):
             NoSuchMetalakeException: If the metalake does not exist.
         """
         return self.get_metalake().list_user_names()
+
+    # Group operations
+
+    def add_group(self, group: str) -> Group:
+        """Add a group to the metalake.
+
+        Args:
+            group: The name of the group.
+
+        Returns:
+            The added Group object.
+
+        Raises:
+            GroupAlreadyExistsException: If a group with the same name already 
exists.
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        return self.get_metalake().add_group(group)
+
+    def remove_group(self, group: str) -> bool:
+        """Remove a group from the metalake.
+
+        Args:
+            group: The name of the group.
+
+        Returns:
+            True if the group was removed, False if the group did not exist.
+
+        Raises:
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        return self.get_metalake().remove_group(group)
+
+    def get_group(self, group: str) -> Group:
+        """Get a group by name from the metalake.
+
+        Args:
+            group: The name of the group.
+
+        Returns:
+            The Group object.
+
+        Raises:
+            NoSuchGroupException: If the group does not exist.
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        return self.get_metalake().get_group(group)
+
+    def list_groups(self) -> list[Group]:
+        """List all groups with details under the metalake.
+
+        Returns:
+            A list of Group objects.
+
+        Raises:
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        return self.get_metalake().list_groups()
+
+    def list_group_names(self) -> list[str]:
+        """List all group names under the metalake.
+
+        Returns:
+            A list of group name strings.
+
+        Raises:
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        return self.get_metalake().list_group_names()
diff --git a/clients/client-python/gravitino/client/gravitino_metalake.py 
b/clients/client-python/gravitino/client/gravitino_metalake.py
index 4528aac591..65c1d4354c 100644
--- a/clients/client-python/gravitino/client/gravitino_metalake.py
+++ b/clients/client-python/gravitino/client/gravitino_metalake.py
@@ -18,6 +18,7 @@
 import logging
 from typing import Dict, List, Optional
 
+from gravitino.api.authorization.group import Group
 from gravitino.api.authorization.owner import Owner
 from gravitino.api.authorization.user import User
 from gravitino.api.catalog import Catalog
@@ -47,6 +48,7 @@ from gravitino.dto.requests.owner_set_request import 
OwnerSetRequest
 from gravitino.dto.requests.tag_create_request import TagCreateRequest
 from gravitino.dto.requests.tag_updates_request import TagUpdatesRequest
 from gravitino.dto.requests.user_add_request import UserAddRequest
+from gravitino.dto.requests.group_add_request import GroupAddRequest
 from gravitino.dto.responses.catalog_list_response import CatalogListResponse
 from gravitino.dto.responses.catalog_response import CatalogResponse
 from gravitino.dto.responses.drop_response import DropResponse
@@ -68,7 +70,13 @@ from gravitino.dto.responses.user_response import (
     UserNamesListResponse,
     UserResponse,
 )
+from gravitino.dto.responses.group_response import (
+    GroupListResponse,
+    GroupNamesListResponse,
+    GroupResponse,
+)
 from gravitino.exceptions.handlers.catalog_error_handler import 
CATALOG_ERROR_HANDLER
+from gravitino.exceptions.handlers.group_error_handler import 
GROUP_ERROR_HANDLER
 from gravitino.exceptions.handlers.job_error_handler import JOB_ERROR_HANDLER
 from gravitino.exceptions.handlers.owner_error_handler import 
OWNER_ERROR_HANDLER
 from gravitino.exceptions.handlers.tag_error_handler import TAG_ERROR_HANDLER
@@ -104,6 +112,8 @@ class GravitinoMetalake(
     # Authorization paths
     API_METALAKES_USERS_PATH = "api/metalakes/{}/users"
     API_METALAKES_USER_PATH = "api/metalakes/{}/users/{}"
+    API_METALAKES_GROUPS_PATH = "api/metalakes/{}/groups"
+    API_METALAKES_GROUP_PATH = "api/metalakes/{}/groups/{}"
 
     def __init__(self, metalake: MetalakeDTO = None, client: HTTPClient = 
None):
         super().__init__(
@@ -883,3 +893,112 @@ class GravitinoMetalake(
         resp = UserNamesListResponse.from_json(response.body, 
infer_missing=True)
         resp.validate()
         return resp.names()
+
+    #####################
+    # Group operations
+    #####################
+
+    def add_group(self, group: str) -> Group:
+        """Add a group to this metalake.
+
+        Args:
+            group: The name of the group.
+
+        Returns:
+            The added Group object.
+
+        Raises:
+            GroupAlreadyExistsException: If a group with the same name already 
exists.
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        Precondition.check_string_not_empty(
+            group, "group name must not be null or empty"
+        )
+        req = GroupAddRequest(group)
+        req.validate()
+        url = self.API_METALAKES_GROUPS_PATH.format(encode_string(self.name()))
+        response = self.rest_client.post(
+            url, json=req, error_handler=GROUP_ERROR_HANDLER
+        )
+        resp = GroupResponse.from_json(response.body, infer_missing=True)
+        resp.validate()
+        return resp.group()
+
+    def remove_group(self, group: str) -> bool:
+        """Remove a group from this metalake.
+
+        Args:
+            group: The name of the group.
+
+        Returns:
+            True if the group was removed, False if the group did not exist.
+
+        Raises:
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        Precondition.check_string_not_empty(
+            group, "group name must not be null or empty"
+        )
+        url = self.API_METALAKES_GROUP_PATH.format(
+            encode_string(self.name()), encode_string(group)
+        )
+        response = self.rest_client.delete(url, 
error_handler=GROUP_ERROR_HANDLER)
+        remove_response = RemoveResponse.from_json(response.body, 
infer_missing=True)
+        remove_response.validate()
+        return remove_response.removed()
+
+    def get_group(self, group: str) -> Group:
+        """Get a group by name from this metalake.
+
+        Args:
+            group: The name of the group.
+
+        Returns:
+            The Group object.
+
+        Raises:
+            NoSuchGroupException: If the group does not exist.
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        Precondition.check_string_not_empty(
+            group, "group name must not be null or empty"
+        )
+        url = self.API_METALAKES_GROUP_PATH.format(
+            encode_string(self.name()), encode_string(group)
+        )
+        response = self.rest_client.get(url, error_handler=GROUP_ERROR_HANDLER)
+        resp = GroupResponse.from_json(response.body, infer_missing=True)
+        resp.validate()
+        return resp.group()
+
+    def list_groups(self) -> list[Group]:
+        """List all groups with details under this metalake.
+
+        Returns:
+            A list of Group objects.
+
+        Raises:
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        url = self.API_METALAKES_GROUPS_PATH.format(encode_string(self.name()))
+        response = self.rest_client.get(
+            url, params={"details": "true"}, error_handler=GROUP_ERROR_HANDLER
+        )
+        resp = GroupListResponse.from_json(response.body, infer_missing=True)
+        resp.validate()
+        return resp.groups()
+
+    def list_group_names(self) -> list[str]:
+        """List all group names under this metalake.
+
+        Returns:
+            A list of group name strings.
+
+        Raises:
+            NoSuchMetalakeException: If the metalake does not exist.
+        """
+        url = self.API_METALAKES_GROUPS_PATH.format(encode_string(self.name()))
+        response = self.rest_client.get(url, error_handler=GROUP_ERROR_HANDLER)
+        resp = GroupNamesListResponse.from_json(response.body, 
infer_missing=True)
+        resp.validate()
+        return resp.names()
diff --git a/clients/client-python/gravitino/dto/authorization/group_dto.py 
b/clients/client-python/gravitino/dto/authorization/group_dto.py
new file mode 100644
index 0000000000..6d0be5e1ea
--- /dev/null
+++ b/clients/client-python/gravitino/dto/authorization/group_dto.py
@@ -0,0 +1,91 @@
+# 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 typing import Optional
+
+from dataclasses_json import config, dataclass_json
+
+from gravitino.api.authorization.group import Group
+from gravitino.dto.audit_dto import AuditDTO
+
+
+@dataclass_json
+@dataclass
+class GroupDTO(Group):
+    """Represents a Group Data Transfer Object (DTO)."""
+
+    _name: str = field(metadata=config(field_name="name"))
+    _roles: tuple[str, ...] = field(
+        default_factory=tuple, metadata=config(field_name="roles")
+    )
+    _audit: Optional[AuditDTO] = field(
+        default=None, metadata=config(field_name="audit")
+    )
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, GroupDTO):
+            return False
+        return (
+            self._name == other._name
+            and self._roles == other._roles
+            and self._audit == other._audit
+        )
+
+    def __hash__(self) -> int:
+        return hash((self._name, tuple(self._roles), self._audit))
+
+    @staticmethod
+    def builder() -> GroupDTO.Builder:
+        return GroupDTO.Builder()
+
+    def name(self) -> str:
+        return self._name
+
+    def roles(self) -> list[str]:
+        return list(self._roles) if self._roles else []
+
+    def audit_info(self) -> Optional[AuditDTO]:
+        return self._audit
+
+    class Builder:
+        """Helper class to build a GroupDTO object."""
+
+        def __init__(self) -> None:
+            self._name: str = ""
+            self._roles: tuple[str, ...] = ()
+            self._audit: Optional[AuditDTO] = None
+
+        def with_name(self, name: str) -> GroupDTO.Builder:
+            self._name = name
+            return self
+
+        def with_roles(self, roles: list[str]) -> GroupDTO.Builder:
+            if roles is not None:
+                self._roles = tuple(roles)
+            return self
+
+        def with_audit(self, audit: AuditDTO) -> GroupDTO.Builder:
+            self._audit = audit
+            return self
+
+        def build(self) -> GroupDTO:
+            if not self._name:
+                raise ValueError("name cannot be null or empty")
+            return GroupDTO(self._name, self._roles, self._audit)
diff --git a/clients/client-python/gravitino/api/authorization/__init__.py 
b/clients/client-python/gravitino/dto/requests/group_add_request.py
similarity index 61%
copy from clients/client-python/gravitino/api/authorization/__init__.py
copy to clients/client-python/gravitino/dto/requests/group_add_request.py
index f1b0f5de98..f83be9e3f5 100644
--- a/clients/client-python/gravitino/api/authorization/__init__.py
+++ b/clients/client-python/gravitino/dto/requests/group_add_request.py
@@ -17,14 +17,22 @@
 
 from __future__ import annotations
 
-from gravitino.api.authorization.privileges import Privileges
-from gravitino.api.authorization.role import Role
-from gravitino.api.authorization.securable_objects import SecurableObjects
-from gravitino.api.authorization.user import User
-
-__all__ = [
-    "Role",
-    "SecurableObjects",
-    "Privileges",
-    "User",
-]
+from dataclasses import dataclass, field
+
+from dataclasses_json import config, dataclass_json
+
+from gravitino.rest.rest_message import RESTRequest
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass_json
+@dataclass
+class GroupAddRequest(RESTRequest):
+    """Represents a request to add a group."""
+
+    _name: str = field(metadata=config(field_name="name"))
+
+    def validate(self) -> None:
+        Precondition.check_string_not_empty(
+            self._name, "name is required and cannot be empty"
+        )
diff --git a/clients/client-python/gravitino/dto/responses/group_response.py 
b/clients/client-python/gravitino/dto/responses/group_response.py
new file mode 100644
index 0000000000..cb3baef68b
--- /dev/null
+++ b/clients/client-python/gravitino/dto/responses/group_response.py
@@ -0,0 +1,79 @@
+# 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 typing import Optional
+
+from dataclasses_json import config, dataclass_json
+
+from gravitino.dto.authorization.group_dto import GroupDTO
+from gravitino.dto.responses.base_response import BaseResponse
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass_json
+@dataclass
+class GroupResponse(BaseResponse):
+    """Represents a response for a group."""
+
+    _group: Optional[GroupDTO] = field(
+        default=None, metadata=config(field_name="group")
+    )
+
+    def group(self) -> Optional[GroupDTO]:
+        return self._group
+
+    def validate(self) -> None:
+        Precondition.check_argument(
+            self._group is not None, "Group response should have a group"
+        )
+
+
+@dataclass_json
+@dataclass
+class GroupListResponse(BaseResponse):
+    """Represents a response for a list of groups with details."""
+
+    _groups: list[GroupDTO] = field(
+        default_factory=list, metadata=config(field_name="groups")
+    )
+
+    def groups(self) -> list[GroupDTO]:
+        return self._groups
+
+    def validate(self) -> None:
+        Precondition.check_argument(
+            self._groups is not None, "Group list response should have groups"
+        )
+
+
+@dataclass_json
+@dataclass
+class GroupNamesListResponse(BaseResponse):
+    """Represents a response for a list of group names."""
+
+    _names: list[str] = field(default_factory=list, 
metadata=config(field_name="names"))
+
+    def names(self) -> list[str]:
+        return self._names
+
+    def validate(self) -> None:
+        Precondition.check_argument(
+            self._names is not None, "Group names list response should have 
names"
+        )
diff --git a/clients/client-python/gravitino/exceptions/base.py 
b/clients/client-python/gravitino/exceptions/base.py
index 354d234f1a..e7fe653a4e 100644
--- a/clients/client-python/gravitino/exceptions/base.py
+++ b/clients/client-python/gravitino/exceptions/base.py
@@ -263,3 +263,11 @@ class NoSuchUserException(NotFoundException):
 
 class UserAlreadyExistsException(AlreadyExistsException):
     """An exception thrown when a user already exists."""
+
+
+class NoSuchGroupException(NotFoundException):
+    """An exception thrown when a group is not found."""
+
+
+class GroupAlreadyExistsException(AlreadyExistsException):
+    """An exception thrown when a group already exists."""
diff --git 
a/clients/client-python/gravitino/exceptions/handlers/group_error_handler.py 
b/clients/client-python/gravitino/exceptions/handlers/group_error_handler.py
new file mode 100644
index 0000000000..5eddb7f397
--- /dev/null
+++ b/clients/client-python/gravitino/exceptions/handlers/group_error_handler.py
@@ -0,0 +1,67 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from gravitino.constants.error import ErrorConstants
+from gravitino.dto.responses.error_response import ErrorResponse
+from gravitino.exceptions.base import (
+    GroupAlreadyExistsException,
+    IllegalArgumentException,
+    MetalakeNotInUseException,
+    NoSuchGroupException,
+    NoSuchMetalakeException,
+    NoSuchRoleException,
+    NotFoundException,
+)
+from gravitino.exceptions.handlers.rest_error_handler import RestErrorHandler
+
+
+class GroupErrorHandler(RestErrorHandler):
+    """Error handler specific to Group 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:
+            raise IllegalArgumentException(error_message)
+
+        if code == ErrorConstants.NOT_FOUND_CODE:
+            if exception_type == NoSuchMetalakeException.__name__:
+                raise NoSuchMetalakeException(error_message)
+
+            if exception_type == NoSuchGroupException.__name__:
+                raise NoSuchGroupException(error_message)
+
+            if exception_type == NoSuchRoleException.__name__:
+                raise NoSuchRoleException(error_message)
+
+            raise NotFoundException(error_message)
+
+        if code == ErrorConstants.ALREADY_EXISTS_CODE:
+            raise GroupAlreadyExistsException(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)
+
+
+GROUP_ERROR_HANDLER = GroupErrorHandler()
diff --git a/clients/client-python/tests/integration/test_group_management.py 
b/clients/client-python/tests/integration/test_group_management.py
new file mode 100644
index 0000000000..1e0a8c0095
--- /dev/null
+++ b/clients/client-python/tests/integration/test_group_management.py
@@ -0,0 +1,124 @@
+# 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 uuid
+
+from gravitino import GravitinoAdminClient, GravitinoClient
+from gravitino.exceptions.base import (
+    GroupAlreadyExistsException,
+    NoSuchGroupException,
+)
+from tests.integration.integration_test_env import IntegrationTestEnv
+
+logger = logging.getLogger(__name__)
+
+
+class TestGroupManagement(IntegrationTestEnv):
+    _metalake_name: str = f"test_group_metalake_{uuid.uuid4().hex[:8]}"
+    _gravitino_admin_client: GravitinoAdminClient = None
+    _gravitino_client: GravitinoClient = None
+
+    @classmethod
+    def setUpClass(cls):
+        cls._get_gravitino_home()
+        conf_path = os.path.join(cls.gravitino_home, "conf", "gravitino.conf")
+        auth_confs = {
+            "gravitino.authorization.enable": "true",
+            "gravitino.authorization.serviceAdmins": "anonymous",
+        }
+        cls._reset_conf(auth_confs, conf_path)
+        cls._append_conf(auth_confs, conf_path)
+        if (
+            os.environ.get("START_EXTERNAL_GRAVITINO") is not None
+            and os.environ.get("START_EXTERNAL_GRAVITINO").lower() == "true"
+        ):
+            cls.restart_server()
+        else:
+            super().setUpClass()
+        cls._gravitino_admin_client = 
GravitinoAdminClient(uri="http://localhost:8090";)
+
+    @classmethod
+    def tearDownClass(cls):
+        conf_path = os.path.join(cls.gravitino_home, "conf", "gravitino.conf")
+        reset_confs = {
+            "gravitino.authorization.enable": "false",
+            "gravitino.authorization.serviceAdmins": "anonymous",
+        }
+        cls._reset_conf(reset_confs, conf_path)
+        cls._append_conf(reset_confs, conf_path)
+        if (
+            os.environ.get("START_EXTERNAL_GRAVITINO") is not None
+            and os.environ.get("START_EXTERNAL_GRAVITINO").lower() == "true"
+        ):
+            cls.restart_server()
+        else:
+            super().tearDownClass()
+
+    def setUp(self):
+        self._gravitino_admin_client.create_metalake(
+            self._metalake_name, comment="test group management", properties={}
+        )
+        self._gravitino_client = GravitinoClient(
+            uri="http://localhost:8090";, metalake_name=self._metalake_name
+        )
+
+    def tearDown(self):
+        try:
+            self._gravitino_admin_client.drop_metalake(self._metalake_name, 
force=True)
+        except Exception:  # pylint: disable=broad-except
+            logger.warning("Failed to drop metalake %s", self._metalake_name)
+
+    def test_add_and_get_group(self):
+        added = self._gravitino_client.add_group("engineers")
+        self.assertEqual("engineers", added.name())
+
+        retrieved = self._gravitino_client.get_group("engineers")
+        self.assertEqual("engineers", retrieved.name())
+
+    def test_add_duplicate_group(self):
+        self._gravitino_client.add_group("engineers")
+        with self.assertRaises(GroupAlreadyExistsException):
+            self._gravitino_client.add_group("engineers")
+
+    def test_list_group_names(self):
+        self._gravitino_client.add_group("engineers")
+        self._gravitino_client.add_group("admins")
+
+        names = self._gravitino_client.list_group_names()
+        self.assertIn("engineers", names)
+        self.assertIn("admins", names)
+
+    def test_list_groups(self):
+        self._gravitino_client.add_group("engineers")
+        self._gravitino_client.add_group("admins")
+
+        groups = self._gravitino_client.list_groups()
+        group_names = [g.name() for g in groups]
+        self.assertIn("engineers", group_names)
+        self.assertIn("admins", group_names)
+
+    def test_remove_group(self):
+        self._gravitino_client.add_group("engineers")
+        self.assertTrue(self._gravitino_client.remove_group("engineers"))
+
+        with self.assertRaises(NoSuchGroupException):
+            self._gravitino_client.get_group("engineers")
+
+    def test_remove_nonexistent_group(self):
+        self.assertFalse(self._gravitino_client.remove_group("nonexistent"))
diff --git a/clients/client-python/tests/integration/test_owner.py 
b/clients/client-python/tests/integration/test_owner.py
index 200724395a..f292fcb4da 100644
--- a/clients/client-python/tests/integration/test_owner.py
+++ b/clients/client-python/tests/integration/test_owner.py
@@ -50,12 +50,8 @@ class TestOwner(IntegrationTestEnv):
     def setUpClass(cls):
         cls._get_gravitino_home()
         conf_path = os.path.join(cls.gravitino_home, "conf", "gravitino.conf")
-        cls._reset_conf(
-            {"gravitino.authorization.enable": "true"}, conf_path
-        )
-        cls._append_conf(
-            {"gravitino.authorization.enable": "true"}, conf_path
-        )
+        cls._reset_conf({"gravitino.authorization.enable": "true"}, conf_path)
+        cls._append_conf({"gravitino.authorization.enable": "true"}, conf_path)
         if (
             os.environ.get("START_EXTERNAL_GRAVITINO") is not None
             and os.environ.get("START_EXTERNAL_GRAVITINO").lower() == "true"
@@ -63,16 +59,12 @@ class TestOwner(IntegrationTestEnv):
             cls.restart_server()
         else:
             super().setUpClass()
-        cls.gravitino_admin_client = GravitinoAdminClient(
-            uri="http://localhost:8090";
-        )
+        cls.gravitino_admin_client = 
GravitinoAdminClient(uri="http://localhost:8090";)
 
     @classmethod
     def tearDownClass(cls):
         conf_path = os.path.join(cls.gravitino_home, "conf", "gravitino.conf")
-        cls._reset_conf(
-            {"gravitino.authorization.enable": "false"}, conf_path
-        )
+        cls._reset_conf({"gravitino.authorization.enable": "false"}, conf_path)
         if (
             os.environ.get("START_EXTERNAL_GRAVITINO") is not None
             and os.environ.get("START_EXTERNAL_GRAVITINO").lower() == "true"
@@ -114,9 +106,7 @@ class TestOwner(IntegrationTestEnv):
             logger.warning("Failed to drop catalog %s", self.catalog_name)
 
         try:
-            self.gravitino_admin_client.drop_metalake(
-                self.metalake_name, force=True
-            )
+            self.gravitino_admin_client.drop_metalake(self.metalake_name, 
force=True)
         except GravitinoRuntimeException:
             logger.warning("Failed to drop metalake %s", self.metalake_name)
 
@@ -135,9 +125,7 @@ class TestOwner(IntegrationTestEnv):
         metalake_obj = MetadataObjects.of(
             [self.metalake_name], MetadataObject.Type.METALAKE
         )
-        self.gravitino_client.set_owner(
-            metalake_obj, self.test_user, Owner.Type.USER
-        )
+        self.gravitino_client.set_owner(metalake_obj, self.test_user, 
Owner.Type.USER)
 
         owner = self.gravitino_client.get_owner(metalake_obj)
         self.assertIsNotNone(owner)
@@ -157,9 +145,7 @@ class TestOwner(IntegrationTestEnv):
         self.assertTrue(len(owner.name()) > 0)
         self.assertEqual(Owner.Type.USER, owner.type())
 
-        self.gravitino_client.set_owner(
-            catalog_obj, self.test_user, Owner.Type.USER
-        )
+        self.gravitino_client.set_owner(catalog_obj, self.test_user, 
Owner.Type.USER)
         owner = self.gravitino_client.get_owner(catalog_obj)
         self.assertEqual(self.test_user, owner.name())
         self.assertEqual(Owner.Type.USER, owner.type())
@@ -171,9 +157,7 @@ class TestOwner(IntegrationTestEnv):
         schema_obj = MetadataObjects.of(
             [self.catalog_name, schema_name], MetadataObject.Type.SCHEMA
         )
-        self.gravitino_client.set_owner(
-            schema_obj, self.test_user, Owner.Type.USER
-        )
+        self.gravitino_client.set_owner(schema_obj, self.test_user, 
Owner.Type.USER)
 
         owner = self.gravitino_client.get_owner(schema_obj)
         self.assertIsNotNone(owner)
diff --git a/clients/client-python/tests/integration/test_user.py 
b/clients/client-python/tests/integration/test_user.py
index 9576461214..9178df7600 100644
--- a/clients/client-python/tests/integration/test_user.py
+++ b/clients/client-python/tests/integration/test_user.py
@@ -41,8 +41,12 @@ class TestUser(IntegrationTestEnv):
     def setUpClass(cls):
         cls._get_gravitino_home()
         conf_path = os.path.join(cls.gravitino_home, "conf", "gravitino.conf")
-        cls._reset_conf({"gravitino.authorization.enable": "true"}, conf_path)
-        cls._append_conf({"gravitino.authorization.enable": "true"}, conf_path)
+        auth_confs = {
+            "gravitino.authorization.enable": "true",
+            "gravitino.authorization.serviceAdmins": "anonymous",
+        }
+        cls._reset_conf(auth_confs, conf_path)
+        cls._append_conf(auth_confs, conf_path)
         if (
             os.environ.get("START_EXTERNAL_GRAVITINO") is not None
             and os.environ.get("START_EXTERNAL_GRAVITINO").lower() == "true"
@@ -55,7 +59,12 @@ class TestUser(IntegrationTestEnv):
     @classmethod
     def tearDownClass(cls):
         conf_path = os.path.join(cls.gravitino_home, "conf", "gravitino.conf")
-        cls._reset_conf({"gravitino.authorization.enable": "false"}, conf_path)
+        reset_confs = {
+            "gravitino.authorization.enable": "false",
+            "gravitino.authorization.serviceAdmins": "anonymous",
+        }
+        cls._reset_conf(reset_confs, conf_path)
+        cls._append_conf(reset_confs, conf_path)
         if (
             os.environ.get("START_EXTERNAL_GRAVITINO") is not None
             and os.environ.get("START_EXTERNAL_GRAVITINO").lower() == "true"
diff --git 
a/clients/client-python/tests/unittests/client/test_metalake_group_operations.py
 
b/clients/client-python/tests/unittests/client/test_metalake_group_operations.py
new file mode 100644
index 0000000000..4aa5963a6b
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/client/test_metalake_group_operations.py
@@ -0,0 +1,268 @@
+# 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.client.gravitino_client import GravitinoClient
+from gravitino.dto.authorization.group_dto import GroupDTO
+from gravitino.dto.requests.group_add_request import GroupAddRequest
+from gravitino.dto.responses.remove_response import RemoveResponse
+from gravitino.dto.responses.group_response import (
+    GroupListResponse,
+    GroupNamesListResponse,
+    GroupResponse,
+)
+from gravitino.exceptions.base import (
+    IllegalArgumentException,
+    NoSuchMetalakeException,
+    NoSuchGroupException,
+    GroupAlreadyExistsException,
+)
+from gravitino.exceptions.handlers.group_error_handler import 
GROUP_ERROR_HANDLER
+from tests.unittests import mock_base
+
+
+def _build_group_dto(name: str = "engineers", roles: list | None = None) -> 
GroupDTO:
+    return (
+        GroupDTO.builder()
+        .with_name(name)
+        .with_roles(roles if roles is not None else [])
+        .with_audit(mock_base.build_audit_info())
+        .build()
+    )
+
+
+class TestMetalakeGroupOperations(unittest.TestCase):
+    METALAKE_GROUPS_PATH = "api/metalakes/metalake_demo/groups"
+    METALAKE_GROUP_PATH = "api/metalakes/metalake_demo/groups/engineers"
+
+    def test_add_group(self):
+        metalake = mock_base.mock_load_metalake()
+        group = _build_group_dto()
+        mock_resp = mock_base.mock_http_response(GroupResponse(0, 
group).to_json())
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.post", 
return_value=mock_resp
+        ) as mock_post:
+            added = metalake.add_group("engineers")
+
+            self.assertEqual("engineers", added.name())
+            self.assertEqual([], added.roles())
+
+            mock_post.assert_called_once()
+            call_args = mock_post.call_args
+            self.assertEqual(self.METALAKE_GROUPS_PATH, call_args.args[0])
+            self.assertIsInstance(call_args.kwargs["json"], GroupAddRequest)
+            self.assertIn('"name": "engineers"', 
call_args.kwargs["json"].to_json())
+            self.assertIs(GROUP_ERROR_HANDLER, 
call_args.kwargs["error_handler"])
+
+    def test_add_group_already_exists(self):
+        metalake = mock_base.mock_load_metalake()
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.post",
+            side_effect=GroupAlreadyExistsException("group engineers already 
exists"),
+        ):
+            with self.assertRaises(GroupAlreadyExistsException):
+                metalake.add_group("engineers")
+
+    def test_add_group_empty_name_rejected(self):
+        metalake = mock_base.mock_load_metalake()
+        with self.assertRaises(IllegalArgumentException):
+            metalake.add_group("")
+
+    def test_get_group(self):
+        metalake = mock_base.mock_load_metalake()
+        group = _build_group_dto(roles=["role_a", "role_b"])
+        mock_resp = mock_base.mock_http_response(GroupResponse(0, 
group).to_json())
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get", 
return_value=mock_resp
+        ) as mock_get:
+            retrieved = metalake.get_group("engineers")
+
+            self.assertEqual("engineers", retrieved.name())
+            self.assertEqual(["role_a", "role_b"], retrieved.roles())
+
+            mock_get.assert_called_once()
+            self.assertEqual(self.METALAKE_GROUP_PATH, 
mock_get.call_args.args[0])
+            self.assertIs(
+                GROUP_ERROR_HANDLER, mock_get.call_args.kwargs["error_handler"]
+            )
+
+    def test_get_group_not_found(self):
+        metalake = mock_base.mock_load_metalake()
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            side_effect=NoSuchGroupException("no such group"),
+        ):
+            with self.assertRaises(NoSuchGroupException):
+                metalake.get_group("engineers")
+
+    def test_remove_group(self):
+        metalake = mock_base.mock_load_metalake()
+        mock_resp = mock_base.mock_http_response(RemoveResponse(0, 
True).to_json())
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.delete", 
return_value=mock_resp
+        ) as mock_delete:
+            self.assertTrue(metalake.remove_group("engineers"))
+
+            mock_delete.assert_called_once()
+            self.assertEqual(self.METALAKE_GROUP_PATH, 
mock_delete.call_args.args[0])
+            self.assertIs(
+                GROUP_ERROR_HANDLER, 
mock_delete.call_args.kwargs["error_handler"]
+            )
+
+    def test_remove_group_returns_false_when_not_removed(self):
+        metalake = mock_base.mock_load_metalake()
+        mock_resp = mock_base.mock_http_response(RemoveResponse(0, 
False).to_json())
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.delete", 
return_value=mock_resp
+        ):
+            self.assertFalse(metalake.remove_group("engineers"))
+
+    def test_list_groups(self):
+        metalake = mock_base.mock_load_metalake()
+        groups = [_build_group_dto("alice"), _build_group_dto("bob")]
+        mock_resp = mock_base.mock_http_response(GroupListResponse(0, 
groups).to_json())
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get", 
return_value=mock_resp
+        ) as mock_get:
+            result = metalake.list_groups()
+
+            self.assertEqual(["alice", "bob"], [u.name() for u in result])
+
+            mock_get.assert_called_once_with(
+                self.METALAKE_GROUPS_PATH,
+                params={"details": "true"},
+                error_handler=GROUP_ERROR_HANDLER,
+            )
+
+    def test_list_group_names(self):
+        metalake = mock_base.mock_load_metalake()
+        mock_resp = mock_base.mock_http_response(
+            GroupNamesListResponse(0, ["alice", "bob"]).to_json()
+        )
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get", 
return_value=mock_resp
+        ) as mock_get:
+            names = metalake.list_group_names()
+
+            self.assertEqual(["alice", "bob"], names)
+
+            mock_get.assert_called_once_with(
+                self.METALAKE_GROUPS_PATH, error_handler=GROUP_ERROR_HANDLER
+            )
+
+    def test_list_groups_metalake_not_found(self):
+        metalake = mock_base.mock_load_metalake()
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            side_effect=NoSuchMetalakeException("metalake not found"),
+        ):
+            with self.assertRaises(NoSuchMetalakeException):
+                metalake.list_groups()
+
+
+class TestGravitinoClientGroupDelegates(unittest.TestCase):
+    """Verify that GravitinoClient correctly delegates Group operations."""
+
+    def _make_client(self):
+        client = GravitinoClient.__new__(GravitinoClient)
+        return client
+
+    def test_client_add_group(self):
+        client = self._make_client()
+        group = _build_group_dto()
+        mock_resp = mock_base.mock_http_response(GroupResponse(0, 
group).to_json())
+        with (
+            patch.object(
+                GravitinoClient,
+                "get_metalake",
+                return_value=mock_base.mock_load_metalake(),
+            ),
+            patch(
+                "gravitino.utils.http_client.HTTPClient.post", 
return_value=mock_resp
+            ),
+        ):
+            result = client.add_group("engineers")
+            self.assertEqual("engineers", result.name())
+
+    def test_client_get_group(self):
+        client = self._make_client()
+        group = _build_group_dto(roles=["r1"])
+        mock_resp = mock_base.mock_http_response(GroupResponse(0, 
group).to_json())
+        with (
+            patch.object(
+                GravitinoClient,
+                "get_metalake",
+                return_value=mock_base.mock_load_metalake(),
+            ),
+            patch("gravitino.utils.http_client.HTTPClient.get", 
return_value=mock_resp),
+        ):
+            result = client.get_group("engineers")
+            self.assertEqual(["r1"], result.roles())
+
+    def test_client_remove_group(self):
+        client = self._make_client()
+        mock_resp = mock_base.mock_http_response(RemoveResponse(0, 
True).to_json())
+        with (
+            patch.object(
+                GravitinoClient,
+                "get_metalake",
+                return_value=mock_base.mock_load_metalake(),
+            ),
+            patch(
+                "gravitino.utils.http_client.HTTPClient.delete", 
return_value=mock_resp
+            ),
+        ):
+            self.assertTrue(client.remove_group("engineers"))
+
+    def test_client_list_groups(self):
+        client = self._make_client()
+        groups = [_build_group_dto("alice"), _build_group_dto("bob")]
+        mock_resp = mock_base.mock_http_response(GroupListResponse(0, 
groups).to_json())
+        with (
+            patch.object(
+                GravitinoClient,
+                "get_metalake",
+                return_value=mock_base.mock_load_metalake(),
+            ),
+            patch("gravitino.utils.http_client.HTTPClient.get", 
return_value=mock_resp),
+        ):
+            result = client.list_groups()
+            self.assertEqual(["alice", "bob"], [u.name() for u in result])
+
+    def test_client_list_group_names(self):
+        client = self._make_client()
+        mock_resp = mock_base.mock_http_response(
+            GroupNamesListResponse(0, ["alice", "bob"]).to_json()
+        )
+        with (
+            patch.object(
+                GravitinoClient,
+                "get_metalake",
+                return_value=mock_base.mock_load_metalake(),
+            ),
+            patch("gravitino.utils.http_client.HTTPClient.get", 
return_value=mock_resp),
+        ):
+            result = client.list_group_names()
+            self.assertEqual(["alice", "bob"], result)
diff --git a/clients/client-python/gravitino/api/authorization/__init__.py 
b/clients/client-python/tests/unittests/dto/requests/test_group_add_request.py
similarity index 55%
copy from clients/client-python/gravitino/api/authorization/__init__.py
copy to 
clients/client-python/tests/unittests/dto/requests/test_group_add_request.py
index f1b0f5de98..a90be72a15 100644
--- a/clients/client-python/gravitino/api/authorization/__init__.py
+++ 
b/clients/client-python/tests/unittests/dto/requests/test_group_add_request.py
@@ -14,17 +14,25 @@
 # 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.privileges import Privileges
-from gravitino.api.authorization.role import Role
-from gravitino.api.authorization.securable_objects import SecurableObjects
-from gravitino.api.authorization.user import User
+import json as _json
+import unittest
+
+from gravitino.dto.requests.group_add_request import GroupAddRequest
+
+
+class TestGroupAddRequest(unittest.TestCase):
+    def test_group_add_request_serde(self) -> None:
+        group_add_request = GroupAddRequest(_name="group1")
+        ser_json = _json.dumps(group_add_request.to_dict())
+        deser_dict = _json.loads(ser_json)
+
+        self.assertEqual("group1", deser_dict["name"])
+
+    def test_group_add_request_validate(self) -> None:
+        group_add_request = GroupAddRequest(_name="group1")
+        group_add_request.validate()
 
-__all__ = [
-    "Role",
-    "SecurableObjects",
-    "Privileges",
-    "User",
-]
+        with self.assertRaises(ValueError):
+            GroupAddRequest(_name="").validate()
diff --git 
a/clients/client-python/tests/unittests/dto/responses/test_group_response.py 
b/clients/client-python/tests/unittests/dto/responses/test_group_response.py
new file mode 100644
index 0000000000..62d05a139e
--- /dev/null
+++ b/clients/client-python/tests/unittests/dto/responses/test_group_response.py
@@ -0,0 +1,76 @@
+# 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 json as _json
+import unittest
+
+from gravitino.dto.authorization.group_dto import GroupDTO
+from gravitino.dto.responses.group_response import (
+    GroupListResponse,
+    GroupNamesListResponse,
+    GroupResponse,
+)
+
+
+class TestGroupResponses(unittest.TestCase):
+    def test_group_response(self):
+        group_dto = GroupDTO.builder().with_name("group1").build()
+        resp = GroupResponse(0, group_dto)
+
+        resp.validate()
+
+        ser_json = _json.dumps(resp.to_dict())
+        deser_dict = _json.loads(ser_json)
+
+        self.assertEqual(group_dto, resp.group())
+        self.assertEqual(0, deser_dict["code"])
+        self.assertIsNotNone(deser_dict.get("group"))
+        self.assertEqual("group1", deser_dict["group"]["name"])
+
+    def test_group_response_validate_no_group(self):
+        resp = GroupResponse(0, None)
+        with self.assertRaises(ValueError):
+            resp.validate()
+
+    def test_group_names_list_response(self):
+        names = ["group1", "group2"]
+        resp = GroupNamesListResponse(0, names)
+
+        resp.validate()
+
+        ser_json = _json.dumps(resp.to_dict())
+        deser_dict = _json.loads(ser_json)
+
+        self.assertEqual(0, deser_dict["code"])
+        self.assertEqual(2, len(deser_dict["names"]))
+        self.assertListEqual(names, deser_dict["names"])
+
+    def test_group_list_response(self):
+        group1 = 
GroupDTO.builder().with_name("group1").with_roles(["r1"]).build()
+        group2 = GroupDTO.builder().with_name("group2").build()
+
+        resp = GroupListResponse(0, [group1, group2])
+
+        ser_json = _json.dumps(resp.to_dict())
+        deser_dict = _json.loads(ser_json)
+
+        self.assertEqual(0, deser_dict["code"])
+        self.assertEqual(2, len(deser_dict["groups"]))
+        self.assertEqual("group1", deser_dict["groups"][0]["name"])
+        self.assertEqual("group2", deser_dict["groups"][1]["name"])
diff --git a/clients/client-python/tests/unittests/dto/test_group_dto.py 
b/clients/client-python/tests/unittests/dto/test_group_dto.py
new file mode 100644
index 0000000000..81dd7c0fcb
--- /dev/null
+++ b/clients/client-python/tests/unittests/dto/test_group_dto.py
@@ -0,0 +1,126 @@
+# 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 json as _json
+import unittest
+
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.authorization.group_dto import GroupDTO
+
+
+class TestGroupDTO(unittest.TestCase):
+    def test_create_group_dto(self):
+        audit = AuditDTO(
+            _creator="admin",
+            _create_time="2024-01-01T00:00:00Z",
+        )
+        group_dto = (
+            GroupDTO.builder()
+            .with_name("test_group")
+            .with_roles(["role1", "role2"])
+            .with_audit(audit)
+            .build()
+        )
+
+        ser_json = _json.dumps(group_dto.to_dict()).encode("utf-8")
+        deser_dict = _json.loads(ser_json)
+        self.assertEqual(deser_dict["name"], "test_group")
+        self.assertEqual(deser_dict["roles"], ["role1", "role2"])
+        self.assertEqual(deser_dict["audit"]["creator"], "admin")
+
+    def test_group_dto_without_roles(self):
+        group_dto = (
+            GroupDTO.builder()
+            .with_name("test_group_no_roles")
+            .with_audit(AuditDTO(_creator="admin", 
_create_time="2024-01-01T00:00:00Z"))
+            .build()
+        )
+
+        ser_json = _json.dumps(group_dto.to_dict()).encode("utf-8")
+        deser_dict = _json.loads(ser_json)
+        self.assertEqual(deser_dict["name"], "test_group_no_roles")
+        self.assertEqual(deser_dict["roles"], [])
+
+    def test_group_dto_methods(self):
+        group_dto = (
+            GroupDTO.builder()
+            .with_name("method_group")
+            .with_roles(["admin_role"])
+            .with_audit(AuditDTO(_creator="admin", 
_create_time="2024-01-01T00:00:00Z"))
+            .build()
+        )
+        self.assertEqual(group_dto.name(), "method_group")
+        self.assertEqual(group_dto.roles(), ["admin_role"])
+        self.assertIsNotNone(group_dto.audit_info())
+
+    def test_builder_empty_name_raises(self):
+        with self.assertRaises(ValueError):
+            GroupDTO.builder().with_audit(
+                AuditDTO(_creator="test", _create_time="2024-01-01T00:00:00Z")
+            ).build()
+
+    def test_equality_and_hash(self):
+        audit = AuditDTO(_creator="test", _create_time="2024-01-01T00:00:00Z")
+
+        group1 = (
+            GroupDTO.builder()
+            .with_name("group1")
+            .with_roles(["r1"])
+            .with_audit(audit)
+            .build()
+        )
+        group2 = (
+            GroupDTO.builder()
+            .with_name("group1")
+            .with_roles(["r1"])
+            .with_audit(audit)
+            .build()
+        )
+        group3 = (
+            GroupDTO.builder()
+            .with_name("group2")
+            .with_roles(["r1"])
+            .with_audit(audit)
+            .build()
+        )
+
+        self.assertEqual(group1, group2)
+        self.assertEqual(hash(group1), hash(group2))
+        self.assertNotEqual(group1, group3)
+
+    def test_roles_returns_list_type(self):
+        group_dto = (
+            GroupDTO.builder()
+            .with_name("test")
+            .with_roles(["r1"])
+            .with_audit(AuditDTO(_creator="admin", 
_create_time="2024-01-01T00:00:00Z"))
+            .build()
+        )
+        self.assertIsInstance(group_dto.roles(), list)
+
+    def test_roles_immutability(self):
+        group_dto = (
+            GroupDTO.builder()
+            .with_name("test")
+            .with_roles(["r1", "r2"])
+            .with_audit(AuditDTO(_creator="admin", 
_create_time="2024-01-01T00:00:00Z"))
+            .build()
+        )
+        roles = group_dto.roles()
+        roles.append("r3")
+        self.assertEqual(["r1", "r2"], group_dto.roles())
diff --git a/clients/client-python/tests/unittests/test_error_handler.py 
b/clients/client-python/tests/unittests/test_error_handler.py
index ca7ceb00a1..10bd9cb281 100644
--- a/clients/client-python/tests/unittests/test_error_handler.py
+++ b/clients/client-python/tests/unittests/test_error_handler.py
@@ -35,6 +35,7 @@ from gravitino.exceptions.base import (
     NoSuchMetalakeException,
     NoSuchPartitionException,
     NoSuchRoleException,
+    NoSuchGroupException,
     NoSuchSchemaException,
     NoSuchTableException,
     NoSuchUserException,
@@ -47,12 +48,14 @@ from gravitino.exceptions.base import (
     TableAlreadyExistsException,
     UnsupportedOperationException,
     UserAlreadyExistsException,
+    GroupAlreadyExistsException,
 )
 from gravitino.exceptions.handlers.catalog_error_handler import 
CATALOG_ERROR_HANDLER
 from gravitino.exceptions.handlers.credential_error_handler import (
     CREDENTIAL_ERROR_HANDLER,
 )
 from gravitino.exceptions.handlers.fileset_error_handler import 
FILESET_ERROR_HANDLER
+from gravitino.exceptions.handlers.group_error_handler import 
GROUP_ERROR_HANDLER
 from gravitino.exceptions.handlers.metalake_error_handler import 
METALAKE_ERROR_HANDLER
 from gravitino.exceptions.handlers.partition_error_handler import (
     PARTITION_ERROR_HANDLER,
@@ -476,3 +479,59 @@ class TestErrorHandler(unittest.TestCase):
             USER_ERROR_HANDLER.handle(
                 ErrorResponse.generate_error_response(Exception, "mock error")
             )
+
+    def test_group_error_handler(self):
+        with self.assertRaises(NoSuchMetalakeException):
+            GROUP_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    NoSuchMetalakeException, "mock error"
+                )
+            )
+
+        with self.assertRaises(NoSuchGroupException):
+            GROUP_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    NoSuchGroupException, "mock error"
+                )
+            )
+
+        with self.assertRaises(NoSuchRoleException):
+            GROUP_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(NoSuchRoleException, 
"mock error")
+            )
+
+        with self.assertRaises(GroupAlreadyExistsException):
+            GROUP_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    GroupAlreadyExistsException, "mock error"
+                )
+            )
+
+        with self.assertRaises(IllegalArgumentException):
+            GROUP_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    IllegalArgumentException, "mock error"
+                )
+            )
+
+        with self.assertRaises(MetalakeNotInUseException):
+            GROUP_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(
+                    MetalakeNotInUseException, "mock error"
+                )
+            )
+
+        with self.assertRaises(NotFoundException):
+            GROUP_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(NotFoundException, "mock 
error")
+            )
+
+        with self.assertRaises(RuntimeError):
+            GROUP_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(InternalError, "mock 
error")
+            )
+
+        with self.assertRaises(RESTException):
+            GROUP_ERROR_HANDLER.handle(
+                ErrorResponse.generate_error_response(Exception, "mock error")
+            )

Reply via email to