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

jshao 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 cf5c480fa0 [#9479] feat(python-client): Add implementation for 
SupportsTags in python client(1/2) (#9483)
cf5c480fa0 is described below

commit cf5c480fa0daa8957816d203a2e55fef7f6df906
Author: Lord of Abyss <[email protected]>
AuthorDate: Mon Feb 2 11:22:36 2026 +0800

    [#9479] feat(python-client): Add implementation for SupportsTags in python 
client(1/2) (#9483)
    
    ### What changes were proposed in this pull request?
    
    Add initial SupportsTags implementation to the Python client, covering
    three interface variants for easier review.
    
    ### Why are the changes needed?
    
    Fix: #9479
    
    ### Does this PR introduce _any_ user-facing change?
    
    no
    
    ### How was this patch tested?
    
    local test
    
    ---------
    
    Co-authored-by: Jerry Shao <[email protected]>
---
 clients/client-python/gravitino/api/tag/tag.py     |  33 ++-
 .../client-python/gravitino/client/generic_tag.py  | 150 +++++++++++++
 .../gravitino/client/gravitino_metalake.py         |  52 ++++-
 clients/client-python/gravitino/dto/audit_dto.py   |  20 ++
 .../gravitino/dto/metadata_object_dto.py           |  95 +++++++++
 .../gravitino/dto/requests/__init__.py             |   6 +
 .../gravitino/dto/requests/tag_create_request.py   |  46 ++++
 .../dto/responses/metadata_object_list_response.py |  59 ++++++
 .../gravitino/dto/responses/tag_response.py        |  76 +++++++
 clients/client-python/gravitino/dto/tag_dto.py     | 152 ++++++++++++++
 clients/client-python/gravitino/exceptions/base.py |   6 +-
 .../exceptions/handlers/oauth_error_handler.py     |   2 +-
 .../exceptions/handlers/tag_error_handler.py       |  70 +++++++
 .../unittests}/dto/requests/__init__.py            |   0
 .../dto/requests/test_tag_create_request.py        |  44 ++++
 .../unittests/dto/responses}/__init__.py           |   0
 .../{ => dto/responses}/test_responses.py          |  47 +++--
 .../unittests/dto/responses/test_tag_response.py   |  92 ++++++++
 .../tests/unittests/dto/test_tag_dto.py            | 100 +++++++++
 .../tests/unittests/test_generic_tag.py            | 232 +++++++++++++++++++++
 20 files changed, 1247 insertions(+), 35 deletions(-)

diff --git a/clients/client-python/gravitino/api/tag/tag.py 
b/clients/client-python/gravitino/api/tag/tag.py
index db8ee0e06c..4845ebe764 100644
--- a/clients/client-python/gravitino/api/tag/tag.py
+++ b/clients/client-python/gravitino/api/tag/tag.py
@@ -69,7 +69,7 @@ class Tag(Auditable):
         Returns:
             str: The name of the tag.
         """
-        pass
+        raise NotImplementedError()
 
     @abstractmethod
     def comment(self) -> str:
@@ -78,7 +78,7 @@ class Tag(Auditable):
         Returns:
             str: The comment of the tag.
         """
-        pass
+        raise NotImplementedError()
 
     @abstractmethod
     def properties(self) -> dict[str, str]:
@@ -87,7 +87,7 @@ class Tag(Auditable):
         Returns:
             Dict[str, str]: The properties of the tag.
         """
-        pass
+        raise NotImplementedError()
 
     @abstractmethod
     def inherited(self) -> Optional[bool]:
@@ -103,7 +103,7 @@ class Tag(Auditable):
                 True if the tag is inherited, false if it is owned by the 
object itself. Empty if the
                 tag is not associated with any object.
         """
-        pass
+        raise NotImplementedError()
 
     def associated_objects(self) -> AssociatedObjects:
         """The associated objects of the tag.
@@ -117,3 +117,28 @@ class Tag(Auditable):
         raise UnsupportedOperationException(
             "The associated_objects method is not supported."
         )
+
+    class AssociatedObjects(ABC):
+        """The interface of the associated objects of the tag."""
+
+        def count(self) -> int:
+            """
+            Retrieve the number of objects that are associated with this Tag
+
+            Returns:
+                int: The number of objects that are associated with this Tag
+            """
+            return 0 if (s := self.objects()) is None else len(s)
+
+        @abstractmethod
+        def objects(self) -> list[MetadataObject]:
+            """
+            Retrieve the list of objects that are associated with this tag.
+
+            Raises:
+                NotImplementedError: if the method is not implemented.
+
+            Returns:
+                list[MetadataObject]: The list of objects that are associated 
with this tag.
+            """
+            raise NotImplementedError()
diff --git a/clients/client-python/gravitino/client/generic_tag.py 
b/clients/client-python/gravitino/client/generic_tag.py
new file mode 100644
index 0000000000..0b865f7046
--- /dev/null
+++ b/clients/client-python/gravitino/client/generic_tag.py
@@ -0,0 +1,150 @@
+# 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 typing import Optional
+
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.tag.tag import Tag
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.responses.metadata_object_list_response import (
+    MetadataObjectListResponse,
+)
+from gravitino.dto.tag_dto import TagDTO
+from gravitino.exceptions.handlers.error_handler import ErrorHandler
+from gravitino.exceptions.handlers.tag_error_handler import TAG_ERROR_HANDLER
+from gravitino.rest.rest_utils import encode_string
+from gravitino.utils import HTTPClient
+from gravitino.utils.http_client import Response
+
+
+class GenericTag(Tag, Tag.AssociatedObjects):
+    """Represents a generic tag."""
+
+    API_LIST_OBJECTS_ENDPOINT = "api/metalakes/{}/tags/{}/objects"
+
+    def __init__(
+        self,
+        metalake: str,
+        tag_dto: TagDTO,
+        client: HTTPClient,
+    ) -> None:
+        self._metalake = metalake
+        self._tag_dto = tag_dto
+        self._client = client
+
+    def __eq__(self, other: object) -> bool:
+        if not isinstance(other, GenericTag):
+            return False
+
+        return self._tag_dto == other._tag_dto
+
+    def __hash__(self) -> int:
+        return hash(self._tag_dto)
+
+    def name(self) -> str:
+        """Get the name of the tag.
+
+        Returns:
+            str: The name of the tag.
+        """
+        return self._tag_dto.name()
+
+    def comment(self) -> str:
+        """Get the comment of the tag.
+
+        Returns:
+            str: The comment of the tag.
+        """
+        return self._tag_dto.comment()
+
+    def properties(self) -> dict[str, str]:
+        """Get the properties of the tag.
+
+        Returns:
+            Dict[str, str]: The properties of the tag.
+        """
+        return self._tag_dto.properties()
+
+    def inherited(self) -> Optional[bool]:
+        """Check if the tag is inherited from a parent object or not.
+
+        If the tag is inherited, it will return `True`, if it is owned by the 
object itself, it will return `False`.
+
+        **Note**. The return value is optional, only when the tag is 
associated with an object, and called from the
+        object, the return value will be present. Otherwise, it will be empty.
+
+        Returns:
+            Optional[bool]:
+                True if the tag is inherited, false if it is owned by the 
object itself. Empty if the
+                tag is not associated with any object.
+        """
+        return self._tag_dto.inherited()
+
+    def audit_info(self) -> AuditDTO:
+        """
+        Retrieve the audit information of the entity.
+
+        Returns:
+            AuditDTO: The audit information of the entity.
+        """
+        return self._tag_dto.audit_info()
+
+    def associated_objects(self) -> Tag.AssociatedObjects:
+        """
+        The associated objects of the tag.
+
+        Returns:
+            AssociatedObjects: The associated objects of the tag.
+        """
+        return self
+
+    def objects(self) -> list[MetadataObject]:
+        """
+        Retrieve the list of objects that are associated with this tag.
+
+        Returns:
+            list[MetadataObject]: The list of objects that are associated with 
this tag.
+        """
+        url = self.API_LIST_OBJECTS_ENDPOINT.format(
+            self._metalake,
+            encode_string(self.name()),
+        )
+
+        response = self.get_response(url, TAG_ERROR_HANDLER)
+        objects_resp = MetadataObjectListResponse.from_json(
+            response.body, infer_missing=True
+        )
+        objects_resp.validate()
+
+        return objects_resp.metadata_objects()
+
+    def get_response(self, url: str, error_handler: ErrorHandler) -> Response:
+        """
+        Get the response from the server, for testing convenience.
+
+        Args:
+            url (str): The url to get the response from.
+            error_handler (ErrorHandlers): The error handler to use.
+
+        Returns:
+            Response: The response from the server.
+        """
+        return self._client.get(
+            url,
+            error_handler=error_handler,
+        )
diff --git a/clients/client-python/gravitino/client/gravitino_metalake.py 
b/clients/client-python/gravitino/client/gravitino_metalake.py
index a10437ce04..00b7ae3694 100644
--- a/clients/client-python/gravitino/client/gravitino_metalake.py
+++ b/clients/client-python/gravitino/client/gravitino_metalake.py
@@ -28,6 +28,7 @@ from gravitino.api.tag.tag import Tag
 from gravitino.api.tag.tag_operations import TagOperations
 from gravitino.client.dto_converters import DTOConverters
 from gravitino.client.generic_job_handle import GenericJobHandle
+from gravitino.client.generic_tag import GenericTag
 from gravitino.dto.metalake_dto import MetalakeDTO
 from gravitino.dto.requests.catalog_create_request import CatalogCreateRequest
 from gravitino.dto.requests.catalog_set_request import CatalogSetRequest
@@ -39,6 +40,7 @@ from gravitino.dto.requests.job_template_register_request 
import (
 from gravitino.dto.requests.job_template_updates_request import (
     JobTemplateUpdatesRequest,
 )
+from gravitino.dto.requests.tag_create_request import TagCreateRequest
 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
@@ -47,10 +49,13 @@ from gravitino.dto.responses.job_list_response import 
JobListResponse
 from gravitino.dto.responses.job_response import JobResponse
 from gravitino.dto.responses.job_template_list_response import 
JobTemplateListResponse
 from gravitino.dto.responses.job_template_response import JobTemplateResponse
+from gravitino.dto.responses.tag_response import TagNamesListResponse, 
TagResponse
 from gravitino.exceptions.handlers.catalog_error_handler import 
CATALOG_ERROR_HANDLER
 from gravitino.exceptions.handlers.job_error_handler import JOB_ERROR_HANDLER
+from gravitino.exceptions.handlers.tag_error_handler import TAG_ERROR_HANDLER
 from gravitino.rest.rest_utils import encode_string
-from gravitino.utils import HTTPClient
+from gravitino.utils.http_client import HTTPClient
+from gravitino.utils.precondition import Precondition
 
 logger = logging.getLogger(__name__)
 
@@ -71,6 +76,8 @@ class GravitinoMetalake(
     API_METALAKES_CATALOGS_PATH = "api/metalakes/{}/catalogs/{}"
     API_METALAKES_JOB_TEMPLATES_PATH = "api/metalakes/{}/jobs/templates"
     API_METALAKES_JOB_RUNS_PATH = "api/metalakes/{}/jobs/runs"
+    API_METALAKES_TAG_PATH = "api/metalakes/{}/tags/{}"
+    API_METALAKES_TAGS_PATH = "api/metalakes/{}/tags"
 
     def __init__(self, metalake: MetalakeDTO = None, client: HTTPClient = 
None):
         super().__init__(
@@ -519,8 +526,13 @@ class GravitinoMetalake(
         Raises:
             NoSuchMetalakeException: If the metalake does not exist.
         """
-        # TODO implement list_tags
-        raise NotImplementedError()
+        url = self.API_METALAKES_TAGS_PATH.format(encode_string(self.name()))
+
+        response = self.rest_client.get(url, error_handler=TAG_ERROR_HANDLER)
+        resp = TagNamesListResponse.from_json(response.body, 
infer_missing=True)
+        resp.validate()
+
+        return resp.tag_names()
 
     def list_tags_info(self) -> List[Tag]:
         """
@@ -548,8 +560,18 @@ class GravitinoMetalake(
         Raises:
             NoSuchTagException: If the tag does not exist.
         """
-        # TODO implement get_tag
-        raise NotImplementedError()
+        Precondition.check_string_not_empty(
+            tag_name, "tag name must not be null or empty"
+        )
+        url = self.API_METALAKES_TAG_PATH.format(
+            encode_string(self.name()), encode_string(tag_name)
+        )
+        response = self.rest_client.get(url, error_handler=TAG_ERROR_HANDLER)
+
+        tag_resp = TagResponse.from_json(response.body, infer_missing=True)
+        tag_resp.validate()
+
+        return GenericTag(self.name(), tag_resp.tag(), self.rest_client)
 
     def create_tag(self, tag_name, comment, properties) -> Tag:
         """
@@ -567,8 +589,24 @@ class GravitinoMetalake(
         Returns:
             Tag: The tag information.
         """
-        # TODO implement create_tag
-        raise NotImplementedError()
+        tag_create_request = TagCreateRequest(
+            tag_name,
+            comment,
+            properties,
+        )
+        tag_create_request.validate()
+
+        url = self.API_METALAKES_TAGS_PATH.format(encode_string(self.name()))
+
+        response = self.rest_client.post(
+            url,
+            json=tag_create_request,
+            error_handler=TAG_ERROR_HANDLER,
+        )
+        tag_resp = TagResponse.from_json(response.body, infer_missing=True)
+        tag_resp.validate()
+
+        return GenericTag(self.name(), tag_resp.tag(), self.rest_client)
 
     def alter_tag(self, tag_name, *changes) -> Tag:
         """
diff --git a/clients/client-python/gravitino/dto/audit_dto.py 
b/clients/client-python/gravitino/dto/audit_dto.py
index c7a92578ca..b175bda344 100644
--- a/clients/client-python/gravitino/dto/audit_dto.py
+++ b/clients/client-python/gravitino/dto/audit_dto.py
@@ -45,6 +45,26 @@ class AuditDTO(Audit, DataClassJsonMixin):
     )  # TODO: Can't deserialized datetime from JSON
     """The last modified time of the audit."""
 
+    def __hash__(self):
+        return hash(
+            (
+                self.creator(),
+                self.create_time(),
+                self.last_modifier(),
+                self.last_modified_time(),
+            )
+        )
+
+    def __eq__(self, other) -> bool:
+        if not isinstance(other, AuditDTO):
+            return False
+        return (
+            self.creator() == other.creator()
+            and self.create_time() == other.create_time()
+            and self.last_modifier() == other.last_modifier()
+            and self.last_modified_time() == other.last_modified_time()
+        )
+
     def creator(self) -> str:
         """The creator of the entity.
 
diff --git a/clients/client-python/gravitino/dto/metadata_object_dto.py 
b/clients/client-python/gravitino/dto/metadata_object_dto.py
new file mode 100644
index 0000000000..6ff0e8cd44
--- /dev/null
+++ b/clients/client-python/gravitino/dto/metadata_object_dto.py
@@ -0,0 +1,95 @@
+# 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.metadata_object import MetadataObject
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass_json
+@dataclass
+class MetadataObjectDTO(MetadataObject):
+    """Represents a Metadata Object DTO (Data Transfer Object)."""
+
+    _type: MetadataObject.Type = field(metadata=config(field_name="type"))
+    _full_name: str = field(metadata=config(field_name="fullName"))
+
+    _name: str = field(init=False, default="")
+    _parent: Optional[str] = field(init=False, default=None)
+
+    def __post_init__(self) -> None:
+        self.set_full_name(self._full_name)
+
+    @staticmethod
+    def builder() -> MetadataObjectDTO.Builder:
+        return MetadataObjectDTO.Builder()
+
+    def type(self) -> MetadataObject.Type:
+        return self._type
+
+    def parent(self) -> Optional[str]:
+        return self._parent
+
+    def name(self) -> str:
+        return self._name
+
+    def set_full_name(self, full_name: str) -> None:
+        """
+        Sets the full name of the metadata object.
+
+        Args:
+            full_name (str): The full name of the metadata object.
+        """
+        index = full_name.rfind(".")
+        self._parent, self._name = (
+            (None, full_name)
+            if index == -1
+            else (full_name[:index], full_name[index + 1 :])
+        )
+
+    class Builder:
+        def __init__(self) -> None:
+            self._full_name: str = ""
+            self._type: MetadataObject.Type = None
+
+        def full_name(self, full_name: str) -> MetadataObjectDTO.Builder:
+            self._full_name = full_name
+            return self
+
+        def type(self, _type: MetadataObject.Type) -> 
MetadataObjectDTO.Builder:
+            self._type = _type
+            return self
+
+        def build(self) -> MetadataObjectDTO:
+            Precondition.check_string_not_empty(
+                self._full_name, "Full name is required and cannot be empty."
+            )
+            Precondition.check_argument(
+                self._type is not None, "Type is required and cannot be None."
+            )
+
+            return MetadataObjectDTO(
+                self._type,
+                self._full_name,
+            )
diff --git a/clients/client-python/gravitino/dto/requests/__init__.py 
b/clients/client-python/gravitino/dto/requests/__init__.py
index 13a83393a9..440b9e645b 100644
--- a/clients/client-python/gravitino/dto/requests/__init__.py
+++ b/clients/client-python/gravitino/dto/requests/__init__.py
@@ -14,3 +14,9 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+
+from gravitino.dto.requests.tag_create_request import TagCreateRequest
+
+__all__ = [
+    "TagCreateRequest",
+]
diff --git a/clients/client-python/gravitino/dto/requests/tag_create_request.py 
b/clients/client-python/gravitino/dto/requests/tag_create_request.py
new file mode 100644
index 0000000000..416d2ac67f
--- /dev/null
+++ b/clients/client-python/gravitino/dto/requests/tag_create_request.py
@@ -0,0 +1,46 @@
+# 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.rest.rest_message import RESTRequest
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass_json
+@dataclass
+class TagCreateRequest(RESTRequest):
+    """Represents a request to create a tag."""
+
+    _name: str = field(metadata=config(field_name="name"))
+    _comment: Optional[str] = field(default=None, 
metadata=config(field_name="comment"))
+    _properties: Optional[dict[str, str]] = field(
+        default_factory=dict, metadata=config(field_name="properties")
+    )
+
+    def validate(self) -> None:
+        """
+        Validate the request.
+        """
+
+        Precondition.check_string_not_empty(
+            self._name, "name is required and cannot be empty"
+        )
diff --git 
a/clients/client-python/gravitino/dto/responses/metadata_object_list_response.py
 
b/clients/client-python/gravitino/dto/responses/metadata_object_list_response.py
new file mode 100644
index 0000000000..eb95322888
--- /dev/null
+++ 
b/clients/client-python/gravitino/dto/responses/metadata_object_list_response.py
@@ -0,0 +1,59 @@
+# 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
+
+from gravitino.dto.metadata_object_dto import MetadataObjectDTO
+from gravitino.dto.responses.base_response import BaseResponse
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass
+class MetadataObjectListResponse(BaseResponse):
+    """Represents a response containing a list of metadata objects."""
+
+    _metadata_objects: list[MetadataObjectDTO] = field(
+        default_factory=list, metadata=config(field_name="metadataObjects")
+    )
+
+    def metadata_objects(self) -> list[MetadataObjectDTO]:
+        """
+        Retrieve the list of metadata objects.
+
+        Returns:
+            list[MetadataObjectDTO]: The list of metadata objects.
+        """
+        return self._metadata_objects
+
+    def validate(self) -> None:
+        """
+        Validates the response data.
+        """
+        super().validate()
+
+        for metadata_object in self._metadata_objects:
+            Precondition.check_argument(
+                metadata_object is not None
+                and metadata_object.type() is not None
+                and (name := metadata_object.name()) is not None
+                and name.strip() != "",
+                "metadataObject must not be null and its field cannot null or 
empty",
+            )
diff --git a/clients/client-python/gravitino/dto/responses/tag_response.py 
b/clients/client-python/gravitino/dto/responses/tag_response.py
new file mode 100644
index 0000000000..e0dea09e71
--- /dev/null
+++ b/clients/client-python/gravitino/dto/responses/tag_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
+
+from dataclasses import dataclass, field
+
+from dataclasses_json import config, dataclass_json
+
+from gravitino.dto.responses.base_response import BaseResponse
+from gravitino.dto.tag_dto import TagDTO
+from gravitino.utils.precondition import Precondition
+
+
+@dataclass_json
+@dataclass
+class TagNamesListResponse(BaseResponse):
+    """Represents a response for a Tag Names List request."""
+
+    _tags: list[str] = field(default_factory=list, 
metadata=config(field_name="names"))
+
+    def tag_names(self) -> list[str]:
+        return self._tags
+
+    def validate(self) -> None:
+        Precondition.check_argument(
+            self._tags is not None, "Tag Names List response should have tags"
+        )
+
+        for tag_name in self._tags:
+            Precondition.check_string_not_empty(
+                tag_name, "Tag Names List response should have non-empty tag 
names"
+            )
+
+
+@dataclass_json
+@dataclass
+class TagListResponse(BaseResponse):
+    """Represents a response for a Tag List request."""
+
+    _tags: list[TagDTO] = field(
+        default_factory=list, metadata=config(field_name="tags")
+    )
+
+    def tags(self) -> list[TagDTO]:
+        return self._tags
+
+
+@dataclass_json
+@dataclass
+class TagResponse(BaseResponse):
+    """Represents a response for a tag."""
+
+    _tag: TagDTO = field(default=None, metadata=config(field_name="tag"))
+
+    def tag(self) -> TagDTO:
+        return self._tag
+
+    def validate(self) -> None:
+        Precondition.check_argument(
+            self._tag is not None, "Tag response should have a tag"
+        )
diff --git a/clients/client-python/gravitino/dto/tag_dto.py 
b/clients/client-python/gravitino/dto/tag_dto.py
new file mode 100644
index 0000000000..8f4568662a
--- /dev/null
+++ b/clients/client-python/gravitino/dto/tag_dto.py
@@ -0,0 +1,152 @@
+# 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.tag.tag import Tag
+from gravitino.dto.audit_dto import AuditDTO
+
+
+@dataclass_json
+@dataclass
+class TagDTO(Tag):
+    """Represents a Tag Data Transfer Object (DTO)."""
+
+    _name: str = field(metadata=config(field_name="name"))
+    _comment: str = field(metadata=config(field_name="comment"))
+    _properties: dict[str, str] = 
field(metadata=config(field_name="properties"))
+
+    _audit: AuditDTO = field(default=None, metadata=config(field_name="audit"))
+    _inherited: Optional[bool] = field(
+        default=None, metadata=config(field_name="inherited")
+    )
+
+    def __eq__(self, other: object):
+        if not isinstance(other, TagDTO):
+            return False
+        return (
+            self._name == other._name
+            and self._comment == other._comment
+            and self._properties == other._properties
+            and self._audit == other._audit
+        )
+
+    def __hash__(self) -> int:
+        return hash(
+            (
+                self._name,
+                self._comment,
+                frozenset(self._properties.items()) if self._properties else 
None,
+                self._audit,
+            )
+        )
+
+    @staticmethod
+    def builder() -> TagDTO.Builder:
+        return TagDTO.Builder()
+
+    def name(self) -> str:
+        """Get the name of the tag.
+
+        Returns:
+            str: The name of the tag.
+        """
+        return self._name
+
+    def comment(self) -> str:
+        """Get the comment of the tag.
+
+        Returns:
+            str: The comment of the tag.
+        """
+        return self._comment
+
+    def properties(self) -> dict[str, str]:
+        """
+        Get the properties of the tag.
+
+        Returns:
+            dict[str, str]: The properties of the tag.
+        """
+        return self._properties
+
+    def audit_info(self) -> AuditDTO:
+        """
+        Get the audit information of the tag.
+
+        Returns:
+            AuditDTO: The audit information of the tag.
+        """
+        return self._audit
+
+    def inherited(self) -> Optional[bool]:
+        """Check if the tag is inherited from a parent object or not.
+
+        If the tag is inherited, it will return `True`, if it is owned by the 
object itself, it will return `False`.
+
+        **Note**. The return value is optional, only when the tag is 
associated with an object, and called from the
+        object, the return value will be present. Otherwise, it will be empty.
+
+        Returns:
+            Optional[bool]:
+                True if the tag is inherited, false if it is owned by the 
object itself. Empty if the
+                tag is not associated with any object.
+        """
+        return self._inherited
+
+    class Builder:
+        """Helper class to build a TagDTO object."""
+
+        def __init__(self) -> None:
+            self._name = ""
+            self._comment = ""
+            self._properties: dict[str, str] = {}
+            self._audit = None
+            self._inherited = True
+
+        def name(self, name: str) -> TagDTO.Builder:
+            self._name = name
+            return self
+
+        def comment(self, comment: str) -> TagDTO.Builder:
+            self._comment = comment
+            return self
+
+        def properties(self, properties: dict[str, str]) -> TagDTO.Builder:
+            self._properties = properties
+            return self
+
+        def audit_info(self, audit: AuditDTO) -> TagDTO.Builder:
+            self._audit = audit
+            return self
+
+        def inherited(self, inherited: bool) -> TagDTO.Builder:
+            self._inherited = inherited
+            return self
+
+        def build(self) -> TagDTO:
+            return TagDTO(
+                self._name,
+                self._comment,
+                self._properties,
+                self._audit,
+                self._inherited,
+            )
diff --git a/clients/client-python/gravitino/exceptions/base.py 
b/clients/client-python/gravitino/exceptions/base.py
index 1661dc6559..a0fd09012d 100644
--- a/clients/client-python/gravitino/exceptions/base.py
+++ b/clients/client-python/gravitino/exceptions/base.py
@@ -174,7 +174,11 @@ class NoSuchTagException(NotFoundException):
 
 
 class TagAlreadyExistsException(AlreadyExistsException):
-    """An exception thrown when a tag with specified name already associated 
to a metadata object."""
+    """An exception thrown when a tag with specified name already exists."""
+
+
+class TagAlreadyAssociatedException(AlreadyExistsException):
+    """Exception thrown when a tag with specified name already associated to a 
metadata object."""
 
 
 class JobTemplateAlreadyExistsException(AlreadyExistsException):
diff --git 
a/clients/client-python/gravitino/exceptions/handlers/oauth_error_handler.py 
b/clients/client-python/gravitino/exceptions/handlers/oauth_error_handler.py
index cce8145e71..aa7d4ddff7 100644
--- a/clients/client-python/gravitino/exceptions/handlers/oauth_error_handler.py
+++ b/clients/client-python/gravitino/exceptions/handlers/oauth_error_handler.py
@@ -15,8 +15,8 @@
 # specific language governing permissions and limitations
 # under the License.
 
-from gravitino.exceptions.base import UnauthorizedException, 
BadRequestException
 from gravitino.dto.responses.oauth2_error_response import OAuth2ErrorResponse
+from gravitino.exceptions.base import BadRequestException, 
UnauthorizedException
 from gravitino.exceptions.handlers.rest_error_handler import RestErrorHandler
 
 INVALID_CLIENT_ERROR = "invalid_client"
diff --git 
a/clients/client-python/gravitino/exceptions/handlers/tag_error_handler.py 
b/clients/client-python/gravitino/exceptions/handlers/tag_error_handler.py
new file mode 100644
index 0000000000..61bd0a5f0e
--- /dev/null
+++ b/clients/client-python/gravitino/exceptions/handlers/tag_error_handler.py
@@ -0,0 +1,70 @@
+# 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.exceptions.base import (
+    AlreadyExistsException,
+    IllegalArgumentException,
+    MetalakeNotInUseException,
+    NoSuchMetalakeException,
+    NoSuchTagException,
+    NotFoundException,
+    TagAlreadyAssociatedException,
+    TagAlreadyExistsException,
+)
+from gravitino.exceptions.handlers.rest_error_handler import RestErrorHandler
+
+
+class TagErrorHandler(RestErrorHandler):
+    """Error handler specific to Tag operations."""
+
+    def handle(self, error_response) -> None:
+        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 == NoSuchTagException.__name__:
+                raise NoSuchTagException(error_message)
+
+            raise NotFoundException(error_message)
+
+        if code == ErrorConstants.ALREADY_EXISTS_CODE:
+            if exception_type == TagAlreadyExistsException.__name__:
+                raise TagAlreadyExistsException(error_message)
+
+            if exception_type == TagAlreadyAssociatedException.__name__:
+                raise TagAlreadyAssociatedException(error_message)
+
+            raise AlreadyExistsException(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)
+
+
+TAG_ERROR_HANDLER = TagErrorHandler()
diff --git a/clients/client-python/gravitino/dto/requests/__init__.py 
b/clients/client-python/tests/unittests/dto/requests/__init__.py
similarity index 100%
copy from clients/client-python/gravitino/dto/requests/__init__.py
copy to clients/client-python/tests/unittests/dto/requests/__init__.py
diff --git 
a/clients/client-python/tests/unittests/dto/requests/test_tag_create_request.py 
b/clients/client-python/tests/unittests/dto/requests/test_tag_create_request.py
new file mode 100644
index 0000000000..40724cceea
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/dto/requests/test_tag_create_request.py
@@ -0,0 +1,44 @@
+# 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.requests import TagCreateRequest
+
+
+class TestTagCreateRequest(unittest.TestCase):
+    def test_tag_create_request_serde(self) -> None:
+        # test without properties
+        tag_create_request = TagCreateRequest("tag_test", "tag comment", None)
+        ser_json = _json.dumps(tag_create_request.to_dict())
+        deser_dict = _json.loads(ser_json)
+
+        self.assertEqual("tag_test", deser_dict["name"])
+        self.assertEqual("tag comment", deser_dict["comment"])
+        self.assertIsNone(deser_dict.get("properties"))
+
+        # test with properties
+        tag_create_request = TagCreateRequest(
+            "tag_test", "tag comment", {"key1": "value1", "key2": "value2"}
+        )
+        ser_json = _json.dumps(tag_create_request.to_dict())
+        deser_dict = _json.loads(ser_json)
+        self.assertEqual("tag_test", deser_dict["name"])
+        self.assertEqual("tag comment", deser_dict["comment"])
+        self.assertIsNotNone(deser_dict.get("properties"))
diff --git a/clients/client-python/gravitino/dto/requests/__init__.py 
b/clients/client-python/tests/unittests/dto/responses/__init__.py
similarity index 100%
copy from clients/client-python/gravitino/dto/requests/__init__.py
copy to clients/client-python/tests/unittests/dto/responses/__init__.py
diff --git a/clients/client-python/tests/unittests/test_responses.py 
b/clients/client-python/tests/unittests/dto/responses/test_responses.py
similarity index 94%
rename from clients/client-python/tests/unittests/test_responses.py
rename to clients/client-python/tests/unittests/dto/responses/test_responses.py
index 51fc4aa340..9b807db384 100644
--- a/clients/client-python/tests/unittests/test_responses.py
+++ b/clients/client-python/tests/unittests/dto/responses/test_responses.py
@@ -14,7 +14,10 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
-import json
+
+from __future__ import annotations
+
+import json as _json
 import unittest
 
 from gravitino.dto.rel.partitions.json_serdes.partition_dto_serdes import (
@@ -198,7 +201,7 @@ class TestResponses(unittest.TestCase):
 
     def test_file_location_response(self):
         json_data = {"code": 0, "fileLocation": "file:/test/1"}
-        json_str = json.dumps(json_data)
+        json_str = _json.dumps(json_data)
         file_location_resp: FileLocationResponse = 
FileLocationResponse.from_json(
             json_str
         )
@@ -207,7 +210,7 @@ class TestResponses(unittest.TestCase):
 
     def test_file_location_response_exception(self):
         json_data = {"code": 0, "fileLocation": ""}
-        json_str = json.dumps(json_data)
+        json_str = _json.dumps(json_data)
         file_location_resp: FileLocationResponse = 
FileLocationResponse.from_json(
             json_str
         )
@@ -216,7 +219,7 @@ class TestResponses(unittest.TestCase):
 
     def test_credential_response(self):
         json_data = {"code": 0, "credentials": []}
-        json_str = json.dumps(json_data)
+        json_str = _json.dumps(json_data)
         credential_resp: CredentialResponse = 
CredentialResponse.from_json(json_str)
         self.assertEqual(0, len(credential_resp.credentials()))
         credential_resp.validate()
@@ -235,7 +238,7 @@ class TestResponses(unittest.TestCase):
                 }
             ],
         }
-        json_str = json.dumps(json_data)
+        json_str = _json.dumps(json_data)
         credential_resp: CredentialResponse = 
CredentialResponse.from_json(json_str)
         credential_resp.validate()
         self.assertEqual(1, len(credential_resp.credentials()))
@@ -262,7 +265,7 @@ class TestResponses(unittest.TestCase):
                 },
             },
         }
-        json_str = json.dumps(json_data)
+        json_str = _json.dumps(json_data)
         model_resp: ModelResponse = ModelResponse.from_json(
             json_str, infer_missing=True
         )
@@ -287,7 +290,7 @@ class TestResponses(unittest.TestCase):
                 },
             },
         }
-        json_str_missing = json.dumps(json_data_missing)
+        json_str_missing = _json.dumps(json_data_missing)
         model_resp_missing: ModelResponse = ModelResponse.from_json(
             json_str_missing, infer_missing=True
         )
@@ -299,7 +302,7 @@ class TestResponses(unittest.TestCase):
 
     def test_model_version_list_response(self):
         json_data = {"code": 0, "versions": [0, 1, 2]}
-        json_str = json.dumps(json_data)
+        json_str = _json.dumps(json_data)
         resp: ModelVersionListResponse = ModelVersionListResponse.from_json(
             json_str, infer_missing=True
         )
@@ -308,7 +311,7 @@ class TestResponses(unittest.TestCase):
         self.assertEqual([0, 1, 2], resp.versions())
 
         json_data_missing = {"code": 0, "versions": []}
-        json_str_missing = json.dumps(json_data_missing)
+        json_str_missing = _json.dumps(json_data_missing)
         resp_missing: ModelVersionListResponse = 
ModelVersionListResponse.from_json(
             json_str_missing, infer_missing=True
         )
@@ -319,7 +322,7 @@ class TestResponses(unittest.TestCase):
         json_data_missing_1 = {
             "code": 0,
         }
-        json_str_missing_1 = json.dumps(json_data_missing_1)
+        json_str_missing_1 = _json.dumps(json_data_missing_1)
         resp_missing_1: ModelVersionListResponse = 
ModelVersionListResponse.from_json(
             json_str_missing_1, infer_missing=True
         )
@@ -340,7 +343,7 @@ class TestResponses(unittest.TestCase):
                 },
             },
         }
-        json_str = json.dumps(json_data)
+        json_str = _json.dumps(json_data)
         resp: ModelVersionResponse = ModelVersionResponse.from_json(
             json_str, infer_missing=True
         )
@@ -365,7 +368,7 @@ class TestResponses(unittest.TestCase):
                 },
             },
         }
-        json_str = json.dumps(json_data)
+        json_str = _json.dumps(json_data)
         resp: ModelVersionResponse = ModelVersionResponse.from_json(
             json_str, infer_missing=True
         )
@@ -385,7 +388,7 @@ class TestResponses(unittest.TestCase):
                 },
             },
         }
-        json_str = json.dumps(json_data)
+        json_str = _json.dumps(json_data)
         resp: ModelVersionResponse = ModelVersionResponse.from_json(
             json_str, infer_missing=True
         )
@@ -401,7 +404,7 @@ class TestResponses(unittest.TestCase):
                 },
             },
         }
-        json_str = json.dumps(json_data)
+        json_str = _json.dumps(json_data)
         resp: ModelVersionResponse = ModelVersionResponse.from_json(
             json_str, infer_missing=True
         )
@@ -414,7 +417,7 @@ class TestResponses(unittest.TestCase):
                 "uris": {"unknown": "http://localhost:8080"},
             },
         }
-        json_str = json.dumps(json_data)
+        json_str = _json.dumps(json_data)
         resp: ModelVersionResponse = ModelVersionResponse.from_json(
             json_str, infer_missing=True
         )
@@ -422,7 +425,7 @@ class TestResponses(unittest.TestCase):
 
     def test_model_version_uri_response(self):
         json_data = {"code": 0, "uri": "s3://path/to/model"}
-        json_str = json.dumps(json_data)
+        json_str = _json.dumps(json_data)
         resp: ModelVersionUriResponse = ModelVersionUriResponse.from_json(
             json_str, infer_missing=True
         )
@@ -430,7 +433,7 @@ class TestResponses(unittest.TestCase):
         self.assertEqual("s3://path/to/model", resp.uri())
 
         json_data_missing = {"code": 0, "uri": ""}
-        json_str_missing = json.dumps(json_data_missing)
+        json_str_missing = _json.dumps(json_data_missing)
         resp_missing: ModelVersionUriResponse = 
ModelVersionUriResponse.from_json(
             json_str_missing, infer_missing=True
         )
@@ -439,7 +442,7 @@ class TestResponses(unittest.TestCase):
         json_data_missing_1 = {
             "code": 0,
         }
-        json_str_missing_1 = json.dumps(json_data_missing_1)
+        json_str_missing_1 = _json.dumps(json_data_missing_1)
         resp_missing_1: ModelVersionUriResponse = 
ModelVersionUriResponse.from_json(
             json_str_missing_1, infer_missing=True
         )
@@ -448,14 +451,14 @@ class TestResponses(unittest.TestCase):
     def test_partition_name_list_response(self):
         partition_names = [f"partition_{i}" for i in range(3)]
         json_data = {"code": 0, "names": partition_names}
-        json_str = json.dumps(json_data)
+        json_str = _json.dumps(json_data)
         resp: PartitionNameListResponse = 
PartitionNameListResponse.from_json(json_str)
         self.assertListEqual(resp.partition_names(), partition_names)
         resp.validate()
 
     def test_partition_name_list_response_exception(self):
         json_data = {"code": 0, "names": None}
-        json_str = json.dumps(json_data)
+        json_str = _json.dumps(json_data)
         resp: PartitionNameListResponse = 
PartitionNameListResponse.from_json(json_str)
         with self.assertRaises(IllegalArgumentException):
             resp.validate()
@@ -468,7 +471,7 @@ class TestResponses(unittest.TestCase):
         }}
         """
         partition = PartitionDTOSerdes.deserialize(
-            json.loads(TestResponses.PARTITION_JSON_STRING)
+            _json.loads(TestResponses.PARTITION_JSON_STRING)
         )
         resp: PartitionResponse = PartitionResponse.from_json(json_string)
         resp.validate()
@@ -483,7 +486,7 @@ class TestResponses(unittest.TestCase):
         """
         partitions = [
             PartitionDTOSerdes.deserialize(
-                json.loads(TestResponses.PARTITION_JSON_STRING)
+                _json.loads(TestResponses.PARTITION_JSON_STRING)
             )
         ]
         resp: PartitionListResponse = 
PartitionListResponse.from_json(json_string)
diff --git 
a/clients/client-python/tests/unittests/dto/responses/test_tag_response.py 
b/clients/client-python/tests/unittests/dto/responses/test_tag_response.py
new file mode 100644
index 0000000000..707558f536
--- /dev/null
+++ b/clients/client-python/tests/unittests/dto/responses/test_tag_response.py
@@ -0,0 +1,92 @@
+# 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.responses.tag_response import (
+    TagListResponse,
+    TagNamesListResponse,
+    TagResponse,
+)
+from gravitino.dto.tag_dto import TagDTO
+
+
+class TestTagResponses(unittest.TestCase):
+    def test_tag_response(self) -> None:
+        tag_dto = TagDTO.builder().name("tag1").comment("comment1").build()
+        tag_resp = TagResponse(0, tag_dto)
+
+        tag_resp.validate()
+
+        # test serialization
+        ser_json = _json.dumps(tag_resp.to_dict())
+        deser_dict = _json.loads(ser_json)
+
+        self.assertEqual(tag_dto, tag_resp.tag())
+        self.assertEqual(0, deser_dict["code"])
+        self.assertIsNotNone(deser_dict.get("tag"))
+        self.assertEqual("tag1", deser_dict["tag"]["name"])
+        self.assertEqual("comment1", deser_dict["tag"]["comment"])
+
+        tag_dto_invalid = TagDTO.builder().build()
+        with self.assertRaises(ValueError):
+            tag_resp = TagResponse(tag_dto_invalid)
+            tag_resp.validate()
+
+    def test_tag_name_list_response(self) -> None:
+        tag_names = ["tag1", "tag2", "tag3"]
+        tag_name_list_resp = TagNamesListResponse(0, tag_names)
+
+        tag_name_list_resp.validate()
+
+        # test serialization
+        ser_json = _json.dumps(tag_name_list_resp.to_dict())
+        deser_dict = _json.loads(ser_json)
+
+        self.assertEqual(0, deser_dict["code"])
+        self.assertIsNotNone(deser_dict.get("names"))
+        self.assertEqual(3, len(deser_dict["names"]))
+        self.assertListEqual(tag_names, deser_dict["names"])
+
+    def test_tag_list_response(self) -> None:
+        tag_dto1 = TagDTO.builder().name("tag1").comment("comment1").build()
+        tag_dto2 = TagDTO.builder().name("tag2").comment("comment2").build()
+        tag_dto3 = TagDTO.builder().name("tag3").comment("comment3").build()
+
+        tag_list_resp = TagListResponse(0, [tag_dto1, tag_dto2, tag_dto3])
+
+        tag_list_resp.validate()
+
+        # test serialization
+        ser_json = _json.dumps(tag_list_resp.to_dict())
+        deser_dict = _json.loads(ser_json)
+
+        self.assertEqual(0, deser_dict["code"])
+        self.assertIsNotNone(deser_dict.get("tags"))
+        self.assertEqual(3, len(deser_dict["tags"]))
+
+        self.assertEqual("tag1", deser_dict["tags"][0]["name"])
+        self.assertEqual("comment1", deser_dict["tags"][0]["comment"])
+
+        self.assertEqual("tag2", deser_dict["tags"][1]["name"])
+        self.assertEqual("comment2", deser_dict["tags"][1]["comment"])
+
+        self.assertEqual("tag3", deser_dict["tags"][2]["name"])
+        self.assertEqual("comment3", deser_dict["tags"][2]["comment"])
diff --git a/clients/client-python/tests/unittests/dto/test_tag_dto.py 
b/clients/client-python/tests/unittests/dto/test_tag_dto.py
new file mode 100644
index 0000000000..d6a73ea5df
--- /dev/null
+++ b/clients/client-python/tests/unittests/dto/test_tag_dto.py
@@ -0,0 +1,100 @@
+# 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.tag_dto import TagDTO
+
+
+class TestTagDTO(unittest.TestCase):
+    def test_create_tag_dto(self):
+        builder = TagDTO.builder()
+        tag_dto = (
+            builder.name("test_tag")
+            .comment("test_comment")
+            .properties(
+                {
+                    "key1": "value1",
+                    "key2": "value2",
+                }
+            )
+            .audit_info(AuditDTO("test_user", 1640995200000))
+            .inherited(True)
+            .build()
+        )
+        ser_json = _json.dumps(tag_dto.to_dict()).encode("utf-8")
+        deser_dict = _json.loads(ser_json)
+        self.assertEqual(deser_dict["name"], "test_tag")
+        self.assertEqual(deser_dict["comment"], "test_comment")
+        self.assertEqual(deser_dict["properties"], {"key1": "value1", "key2": 
"value2"})
+        self.assertTrue(deser_dict["inherited"])
+        self.assertEqual(deser_dict["audit"]["creator"], "test_user")
+        self.assertEqual(deser_dict["audit"]["createTime"], 1640995200000)
+
+    def test_equality_and_hash(self):
+        builder = TagDTO.builder()
+        tag_dto1 = (
+            builder.name("test_tag")
+            .comment("test_comment")
+            .properties(
+                {
+                    "key1": "value1",
+                    "key2": "value2",
+                }
+            )
+            .audit_info(AuditDTO("test_user", 1640995200000))
+            .inherited(True)
+            .build()
+        )
+        tag_dto2 = (
+            builder.name("test_tag")
+            .comment("test_comment")
+            .properties(
+                {
+                    "key1": "value1",
+                    "key2": "value2",
+                }
+            )
+            .audit_info(AuditDTO("test_user", 1640995200000))
+            .inherited(True)
+            .build()
+        )
+        tag_dto3 = (
+            builder.name("test_tag")
+            .comment("test_comment")
+            .properties(
+                {
+                    "key1": "value2",
+                    "key2": "value3",
+                }
+            )
+            .audit_info(AuditDTO("test_user", 1640995200000))
+            .inherited(False)
+            .build()
+        )
+
+        self.assertEqual(tag_dto1, tag_dto2)
+        self.assertEqual(hash(tag_dto1), hash(tag_dto2))
+
+        self.assertNotEqual(tag_dto1, tag_dto3)
+        self.assertNotEqual(hash(tag_dto1), hash(tag_dto3))
+
+        self.assertNotEqual(tag_dto2, tag_dto3)
+        self.assertNotEqual(hash(tag_dto2), hash(tag_dto3))
diff --git a/clients/client-python/tests/unittests/test_generic_tag.py 
b/clients/client-python/tests/unittests/test_generic_tag.py
new file mode 100644
index 0000000000..2fee407b43
--- /dev/null
+++ b/clients/client-python/tests/unittests/test_generic_tag.py
@@ -0,0 +1,232 @@
+# 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 unittest.mock import MagicMock
+
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.client.generic_tag import GenericTag
+from gravitino.dto.audit_dto import AuditDTO
+from gravitino.dto.metadata_object_dto import MetadataObjectDTO
+from gravitino.dto.tag_dto import TagDTO
+from gravitino.exceptions.base import InternalError, NoSuchMetalakeException
+from gravitino.utils import HTTPClient
+from gravitino.utils.http_client import Response
+
+
+class TestGenericTag(unittest.TestCase):
+    METALAKE = "metalake1"
+
+    TAG_DTO = (
+        TagDTO.Builder()
+        .name("tag1")
+        .comment("comment1")
+        .properties({"key1": "value1"})
+        .inherited(True)
+        .audit_info(AuditDTO(_creator="test", 
_create_time="2022-01-01T00:00:00Z"))
+        .build()
+    )
+
+    @classmethod
+    def setUpClass(cls):
+        cls._rest_client = HTTPClient("http://localhost:8090";)
+        cls._metalake_name = "metalake_demo"
+
+    def test_create_generic_tag(self):
+        generic_tag = GenericTag(self.METALAKE, self.TAG_DTO, 
self._rest_client)
+        self.assertEqual("tag1", generic_tag.name())
+        self.assertEqual("comment1", generic_tag.comment())
+        self.assertEqual({"key1": "value1"}, generic_tag.properties())
+        self.assertEqual(True, generic_tag.inherited())
+        self.assertEqual(
+            AuditDTO(_creator="test", _create_time="2022-01-01T00:00:00Z"),
+            generic_tag.audit_info(),
+        )
+
+    def test_generic_tag_associated_objects(self):
+        # Test normal situation
+        response_body = {
+            "code": 0,
+            "metadataObjects": [
+                {
+                    "fullName": "catalog1",
+                    "type": "catalog",
+                },
+                {
+                    "fullName": "catalog1.schema1",
+                    "type": "schema",
+                },
+                {
+                    "fullName": "catalog1.schema1.table1",
+                    "type": "table",
+                },
+                {
+                    "fullName": "catalog1.schema1.table1.column1",
+                    "type": "column",
+                },
+            ],
+        }
+
+        expected_associated_objects = [
+            MetadataObjectDTO.builder()
+            .full_name("catalog1")
+            .type(MetadataObject.Type.CATALOG)
+            .build(),
+            MetadataObjectDTO.builder()
+            .full_name("catalog1.schema1")
+            .type(MetadataObject.Type.SCHEMA)
+            .build(),
+            MetadataObjectDTO.builder()
+            .full_name("catalog1.schema1.table1")
+            .type(MetadataObject.Type.TABLE)
+            .build(),
+            MetadataObjectDTO.builder()
+            .full_name("catalog1.schema1.table1.column1")
+            .type(MetadataObject.Type.COLUMN)
+            .build(),
+        ]
+        generic_tag = TestGenericTagEntity(
+            self.METALAKE, self.TAG_DTO, self._rest_client, response_body
+        )
+        objects = generic_tag.associated_objects().objects()
+        self.assertEqual(4, len(objects))
+        self.assertEqual(4, generic_tag.count())
+
+        for i, v in enumerate(objects):
+            self.assertEqual(v, expected_associated_objects[i])
+            self.assertEqual(v.full_name(), 
expected_associated_objects[i].full_name())
+            self.assertEqual(v.type(), expected_associated_objects[i].type())
+            self.assertEqual(v.parent(), 
expected_associated_objects[i].parent())
+            self.assertEqual(v.name(), expected_associated_objects[i].name())
+
+        # Test return empty array
+        generic_tag = TestGenericTagEntity(
+            self.METALAKE,
+            self.TAG_DTO,
+            self._rest_client,
+            {
+                "code": 0,
+                "metadataObjects": [],
+            },
+        )
+        objects = generic_tag.associated_objects().objects()
+        self.assertEqual(0, len(objects))
+        self.assertEqual(0, generic_tag.count())
+
+        # Test throw NoSuchMetalakeException
+        with self.assertRaises(NoSuchMetalakeException):
+            generic_tag = TestGenericTagEntity(
+                self.METALAKE,
+                self.TAG_DTO,
+                self._rest_client,
+                {
+                    "code": 0,
+                    "metadataObjects": [],
+                },
+                NoSuchMetalakeException,
+            )
+            generic_tag.associated_objects().objects()
+
+        # Test throw internal error
+        with self.assertRaises(InternalError):
+            generic_tag = TestGenericTagEntity(
+                self.METALAKE,
+                self.TAG_DTO,
+                self._rest_client,
+                {
+                    "code": 0,
+                    "metadataObjects": [],
+                },
+                InternalError,
+            )
+            generic_tag.associated_objects().objects()
+
+    def test_hash_and_equal(self) -> None:
+        tag_dto1 = (
+            TagDTO.Builder()
+            .name("tag1")
+            .comment("comment1")
+            .properties({"key1": "value1"})
+            .inherited(True)
+            .audit_info(AuditDTO(_creator="test", 
_create_time="2022-01-01T00:00:00Z"))
+            .build()
+        )
+        generic_tag1 = GenericTag(self.METALAKE, tag_dto1, self._rest_client)
+
+        tag_dto2 = (
+            TagDTO.Builder()
+            .name("tag1")
+            .comment("comment1")
+            .properties({"key1": "value1"})
+            .inherited(True)
+            .audit_info(AuditDTO(_creator="test", 
_create_time="2022-01-01T00:00:00Z"))
+            .build()
+        )
+        generic_tag2 = GenericTag(self.METALAKE, tag_dto2, self._rest_client)
+
+        tag_dto3 = (
+            TagDTO.Builder()
+            .name("tag1")
+            .comment("comment1")
+            .properties({"key1": "value1"})
+            .inherited(True)
+            .audit_info(AuditDTO(_creator="test", 
_create_time="2022-01-02T00:00:00Z"))
+            .build()
+        )
+        generic_tag3 = GenericTag(self.METALAKE, tag_dto3, self._rest_client)
+
+        self.assertEqual(generic_tag1, generic_tag2)
+        self.assertEqual(hash(generic_tag1), hash(generic_tag2))
+
+        self.assertNotEqual(generic_tag1, generic_tag3)
+        self.assertNotEqual(hash(generic_tag1), hash(generic_tag3))
+
+
+class TestGenericTagEntity(GenericTag):
+    def __init__(
+        self,
+        metalake,
+        tag_dto,
+        client,
+        dump_object=None,
+        throw_error=None,
+    ) -> None:
+        super().__init__(
+            metalake,
+            tag_dto,
+            client,
+        )
+        self.__dump_object = dump_object
+        self.__throw_error = throw_error
+
+    def get_response(self, url, _=None) -> Response[MagicMock]:
+        if self.__throw_error is not None:
+            raise self.__throw_error(f"Raise {self.__throw_error.__name__} 
Error")
+
+        mock_response = MagicMock()
+        mock_response.getcode.return_value = 0
+        mock_response.read.return_value = 
_json.dumps(self.__dump_object).encode(
+            "utf-8"
+        )
+
+        mock_response.url = url
+        mock_response.info.return_value = {"Content-Type": "application/json"}
+
+        return Response(mock_response)

Reply via email to