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 19959a5eb0 [#10571] Improvement(client-python): Implement SupportsTags 
related methods (#10670)
19959a5eb0 is described below

commit 19959a5eb01492eeb9208c2ceb3e8df0ea9311f3
Author: Lord of Abyss <[email protected]>
AuthorDate: Wed Apr 22 18:13:34 2026 +0800

    [#10571] Improvement(client-python): Implement SupportsTags related methods 
(#10670)
    
    ### What changes were proposed in this pull request?
    
    Implement SupportsTags related methods
    
    ### Why are the changes needed?
    
    Fix: #10571
    
    ### Does this PR introduce _any_ user-facing change?
    
    `Columns`, `filesets`, `model catalog`, and `relation table` now support
    tagging operations.
    
    ### How was this patch tested?
    
    local unittest
---
 .../gravitino/client/base_schema_catalog.py        |  54 +++++-
 .../gravitino/client/generic_column.py             |  33 +++-
 .../gravitino/client/generic_fileset.py            |  26 ++-
 .../gravitino/client/generic_model.py              |  46 ++++-
 .../gravitino/client/generic_model_catalog.py      |   8 +-
 .../gravitino/client/generic_schema.py             |  93 ++++++++++
 .../client/metadata_object_tag_operations.py       | 131 ++++++++++++++
 .../gravitino/client/relational_table.py           |  35 +++-
 clients/client-python/gravitino/dto/model_dto.py   |  28 ++-
 .../dto/requests/tag_associate_request.py          |  71 ++++++++
 clients/client-python/gravitino/dto/schema_dto.py  |  30 +++-
 .../client/test_metadata_object_tag_operations.py  | 188 +++++++++++++++++++++
 .../dto/requests/test_tags_associate_request.py    |  61 +++++++
 clients/client-python/tests/unittests/mock_base.py |  66 ++++++++
 .../tests/unittests/test_generic_column.py         |  32 ++++
 .../tests/unittests/test_generic_model.py          |  70 ++++++++
 .../tests/unittests/test_generic_schema.py         |  70 ++++++++
 .../tests/unittests/test_relational_table.py       |  22 +++
 18 files changed, 1045 insertions(+), 19 deletions(-)

diff --git a/clients/client-python/gravitino/client/base_schema_catalog.py 
b/clients/client-python/gravitino/client/base_schema_catalog.py
index 3160f5bd6e..52d3d171f0 100644
--- a/clients/client-python/gravitino/client/base_schema_catalog.py
+++ b/clients/client-python/gravitino/client/base_schema_catalog.py
@@ -19,20 +19,24 @@ import logging
 from typing import Dict, List, Optional
 
 from gravitino.api.catalog import Catalog
-from gravitino.api.metadata_object import MetadataObject
-from gravitino.api.metadata_objects import MetadataObjects
 from gravitino.api.function.function import Function
 from gravitino.api.function.function_catalog import FunctionCatalog
 from gravitino.api.function.function_change import FunctionChange
 from gravitino.api.function.function_definition import FunctionDefinition
 from gravitino.api.function.function_type import FunctionType
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.metadata_objects import MetadataObjects
 from gravitino.api.schema import Schema
 from gravitino.api.schema_change import SchemaChange
 from gravitino.api.supports_schemas import SupportsSchemas
+from gravitino.api.tag.supports_tags import SupportsTags
+from gravitino.api.tag.tag import Tag
 from gravitino.client.function_catalog_operations import 
FunctionCatalogOperations
+from gravitino.client.generic_schema import GenericSchema
 from gravitino.client.metadata_object_credential_operations import (
     MetadataObjectCredentialOperations,
 )
+from gravitino.client.metadata_object_tag_operations import 
MetadataObjectTagOperations
 from gravitino.dto.audit_dto import AuditDTO
 from gravitino.dto.catalog_dto import CatalogDTO
 from gravitino.dto.requests.schema_create_request import SchemaCreateRequest
@@ -51,7 +55,12 @@ from gravitino.utils import HTTPClient
 logger = logging.getLogger(__name__)
 
 
-class BaseSchemaCatalog(CatalogDTO, SupportsSchemas, FunctionCatalog):
+class BaseSchemaCatalog(
+    CatalogDTO,
+    SupportsSchemas,
+    FunctionCatalog,
+    SupportsTags,
+):
     """
     BaseSchemaCatalog is the base abstract class for all the catalog with 
schema. It provides the
     common methods for managing schemas in a catalog. With BaseSchemaCatalog, 
users can list,
@@ -98,6 +107,9 @@ class BaseSchemaCatalog(CatalogDTO, SupportsSchemas, 
FunctionCatalog):
         self._function_operations = FunctionCatalogOperations(
             rest_client, catalog_namespace, self.name()
         )
+        self._object_tag_operations = MetadataObjectTagOperations(
+            catalog_namespace.level(0), metadata_object, rest_client
+        )
 
         self.validate()
 
@@ -158,7 +170,12 @@ class BaseSchemaCatalog(CatalogDTO, SupportsSchemas, 
FunctionCatalog):
         schema_response = SchemaResponse.from_json(resp.body, 
infer_missing=True)
         schema_response.validate()
 
-        return schema_response.schema()
+        return GenericSchema(
+            schema_response.schema(),
+            self.rest_client,
+            self._catalog_namespace.level(0),
+            self._name,
+        )
 
     def load_schema(self, schema_name: str) -> Schema:
         """Load the schema with specified identifier.
@@ -181,7 +198,12 @@ class BaseSchemaCatalog(CatalogDTO, SupportsSchemas, 
FunctionCatalog):
         schema_response = SchemaResponse.from_json(resp.body, 
infer_missing=True)
         schema_response.validate()
 
-        return schema_response.schema()
+        return GenericSchema(
+            schema_response.schema(),
+            self.rest_client,
+            self._catalog_namespace.level(0),
+            self._name,
+        )
 
     def alter_schema(self, schema_name: str, *changes: SchemaChange) -> Schema:
         """Alter the schema with specified identifier by applying the changes.
@@ -210,7 +232,13 @@ class BaseSchemaCatalog(CatalogDTO, SupportsSchemas, 
FunctionCatalog):
         )
         schema_response = SchemaResponse.from_json(resp.body, 
infer_missing=True)
         schema_response.validate()
-        return schema_response.schema()
+
+        return GenericSchema(
+            schema_response.schema(),
+            self.rest_client,
+            self._catalog_namespace.level(0),
+            self._name,
+        )
 
     def drop_schema(self, schema_name: str, cascade: bool) -> bool:
         """Drop the schema with specified identifier.
@@ -310,3 +338,17 @@ class BaseSchemaCatalog(CatalogDTO, SupportsSchemas, 
FunctionCatalog):
             raise IllegalArgumentException("provider must not be blank")
         if self.audit_info() is None:
             raise IllegalArgumentException("audit must not be None")
+
+    def list_tags(self) -> List[str]:
+        return self._object_tag_operations.list_tags()
+
+    def list_tags_info(self) -> List[Tag]:
+        return self._object_tag_operations.list_tags_info()
+
+    def get_tag(self, name: str) -> Tag:
+        return self._object_tag_operations.get_tag(name)
+
+    def associate_tags(
+        self, tags_to_add: List[str], tags_to_remove: List[str]
+    ) -> List[str]:
+        return self._object_tag_operations.associate_tags(tags_to_add, 
tags_to_remove)
diff --git a/clients/client-python/gravitino/client/generic_column.py 
b/clients/client-python/gravitino/client/generic_column.py
index dd6aff5a6b..dbb473aaa6 100644
--- a/clients/client-python/gravitino/client/generic_column.py
+++ b/clients/client-python/gravitino/client/generic_column.py
@@ -17,13 +17,18 @@
 
 from typing import Optional, cast
 
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.metadata_objects import MetadataObjects
 from gravitino.api.rel.column import Column
 from gravitino.api.rel.expressions.expression import Expression
 from gravitino.api.rel.types.type import Type
+from gravitino.api.tag.supports_tags import SupportsTags
+from gravitino.api.tag.tag import Tag
+from gravitino.client.metadata_object_tag_operations import 
MetadataObjectTagOperations
 from gravitino.utils import HTTPClient
 
 
-class GenericColumn(Column):
+class GenericColumn(Column, SupportsTags):
     """Represents a generic column."""
 
     def __init__(
@@ -36,6 +41,15 @@ class GenericColumn(Column):
         table: str,
     ):
         self._internal_column = column
+        column_object = MetadataObjects.of(
+            [catalog, schema, table, column.name()],
+            MetadataObject.Type.COLUMN,
+        )
+        self._object_tag_operations = MetadataObjectTagOperations(
+            metalake,
+            column_object,
+            rest_client,
+        )
 
     def name(self) -> str:
         return self._internal_column.name()
@@ -63,3 +77,20 @@ class GenericColumn(Column):
             return False
         column = cast(GenericColumn, value)
         return self._internal_column == column._internal_column
+
+    def supports_tags(self) -> SupportsTags:
+        return self
+
+    def list_tags(self) -> list[str]:
+        return self._object_tag_operations.list_tags()
+
+    def list_tags_info(self) -> list[Tag]:
+        return self._object_tag_operations.list_tags_info()
+
+    def get_tag(self, name: str) -> Tag:
+        return self._object_tag_operations.get_tag(name)
+
+    def associate_tags(
+        self, tags_to_add: list[str], tags_to_remove: list[str]
+    ) -> list[str]:
+        return self._object_tag_operations.associate_tags(tags_to_add, 
tags_to_remove)
diff --git a/clients/client-python/gravitino/client/generic_fileset.py 
b/clients/client-python/gravitino/client/generic_fileset.py
index b2adb21cc4..f002f76620 100644
--- a/clients/client-python/gravitino/client/generic_fileset.py
+++ b/clients/client-python/gravitino/client/generic_fileset.py
@@ -21,16 +21,23 @@ from gravitino.api.credential.supports_credentials import 
SupportsCredentials
 from gravitino.api.file.fileset import Fileset
 from gravitino.api.metadata_object import MetadataObject
 from gravitino.api.metadata_objects import MetadataObjects
+from gravitino.api.tag.supports_tags import SupportsTags
+from gravitino.api.tag.tag import Tag
 from gravitino.client.metadata_object_credential_operations import (
     MetadataObjectCredentialOperations,
 )
+from gravitino.client.metadata_object_tag_operations import 
MetadataObjectTagOperations
 from gravitino.dto.audit_dto import AuditDTO
 from gravitino.dto.fileset_dto import FilesetDTO
 from gravitino.namespace import Namespace
 from gravitino.utils import HTTPClient
 
 
-class GenericFileset(Fileset, SupportsCredentials):
+class GenericFileset(
+    Fileset,
+    SupportsCredentials,
+    SupportsTags,
+):
     _fileset: FilesetDTO
     """The fileset data transfer object"""
 
@@ -48,6 +55,9 @@ class GenericFileset(Fileset, SupportsCredentials):
         self._object_credential_operations = 
MetadataObjectCredentialOperations(
             full_namespace.level(0), metadata_object, rest_client
         )
+        self._object_tag_operations = MetadataObjectTagOperations(
+            full_namespace.level(0), metadata_object, rest_client
+        )
 
     def name(self) -> str:
         return self._fileset.name()
@@ -72,3 +82,17 @@ class GenericFileset(Fileset, SupportsCredentials):
 
     def get_credentials(self) -> List[Credential]:
         return self._object_credential_operations.get_credentials()
+
+    def list_tags(self) -> List[str]:
+        return self._object_tag_operations.list_tags()
+
+    def list_tags_info(self) -> List[Tag]:
+        return self._object_tag_operations.list_tags_info()
+
+    def get_tag(self, name: str) -> Tag:
+        return self._object_tag_operations.get_tag(name)
+
+    def associate_tags(
+        self, tags_to_add: List[str], tags_to_remove: List[str]
+    ) -> List[str]:
+        return self._object_tag_operations.associate_tags(tags_to_add, 
tags_to_remove)
diff --git a/clients/client-python/gravitino/client/generic_model.py 
b/clients/client-python/gravitino/client/generic_model.py
index 882383a119..e4ed97dc98 100644
--- a/clients/client-python/gravitino/client/generic_model.py
+++ b/clients/client-python/gravitino/client/generic_model.py
@@ -14,19 +14,47 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+
+from __future__ import annotations
+
 from typing import Optional
 
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.metadata_objects import MetadataObjects
 from gravitino.api.model.model import Model
+from gravitino.api.tag import Tag
+from gravitino.api.tag.supports_tags import SupportsTags
+from gravitino.client.metadata_object_tag_operations import 
MetadataObjectTagOperations
 from gravitino.dto.audit_dto import AuditDTO
 from gravitino.dto.model_dto import ModelDTO
+from gravitino.namespace import Namespace
+from gravitino.utils import HTTPClient
 
 
-class GenericModel(Model):
+class GenericModel(Model, SupportsTags):
     _model_dto: ModelDTO
     """The model DTO object."""
 
-    def __init__(self, model_dto: ModelDTO):
+    def __init__(
+        self, model_dto: ModelDTO, rest_client: HTTPClient, model_ns: Namespace
+    ) -> None:
         self._model_dto = model_dto
+        model_full_name = [model_ns.level(1), model_ns.level(2), 
model_dto.name()]
+        model_object: MetadataObject = MetadataObjects.of(
+            model_full_name, MetadataObject.Type.MODEL
+        )
+        self._model_tag_operations = MetadataObjectTagOperations(
+            model_ns.level(0), model_object, rest_client
+        )
+
+    def __eq__(self, value: object) -> bool:
+        if not isinstance(value, GenericModel):
+            return False
+
+        return self._model_dto == value._model_dto
+
+    def __hash__(self) -> int:
+        return hash(self._model_dto)
 
     def name(self) -> str:
         return self._model_dto.name()
@@ -42,3 +70,17 @@ class GenericModel(Model):
 
     def audit_info(self) -> AuditDTO:
         return self._model_dto.audit_info()
+
+    def list_tags(self) -> list[str]:
+        return self._model_tag_operations.list_tags()
+
+    def list_tags_info(self) -> list[Tag]:
+        return self._model_tag_operations.list_tags_info()
+
+    def get_tag(self, name: str) -> Tag:
+        return self._model_tag_operations.get_tag(name)
+
+    def associate_tags(
+        self, tags_to_add: list[str], tags_to_remove: list[str]
+    ) -> list[str]:
+        return self._model_tag_operations.associate_tags(tags_to_add, 
tags_to_remove)
diff --git a/clients/client-python/gravitino/client/generic_model_catalog.py 
b/clients/client-python/gravitino/client/generic_model_catalog.py
index bf8065addc..cad07595cf 100644
--- a/clients/client-python/gravitino/client/generic_model_catalog.py
+++ b/clients/client-python/gravitino/client/generic_model_catalog.py
@@ -53,7 +53,7 @@ from gravitino.rest.rest_utils import encode_string
 from gravitino.utils import HTTPClient
 
 
-class GenericModelCatalog(BaseSchemaCatalog):
+class GenericModelCatalog(BaseSchemaCatalog):  # pylint: disable=R0901
     """
     The generic model catalog is a catalog that supports model and model 
version operations,
     for example, model register, model version link, model and model version 
list, etc.
@@ -134,7 +134,7 @@ class GenericModelCatalog(BaseSchemaCatalog):
         model_resp = ModelResponse.from_json(resp.body, infer_missing=True)
         model_resp.validate()
 
-        return GenericModel(model_resp.model())
+        return GenericModel(model_resp.model(), self.rest_client, 
model_full_ns)
 
     def register_model(
         self, ident: NameIdentifier, comment: str, properties: Dict[str, str]
@@ -172,7 +172,7 @@ class GenericModelCatalog(BaseSchemaCatalog):
         model_resp = ModelResponse.from_json(resp.body, infer_missing=True)
         model_resp.validate()
 
-        return GenericModel(model_resp.model())
+        return GenericModel(model_resp.model(), self.rest_client, 
model_full_ns)
 
     def delete_model(self, model_ident: NameIdentifier) -> bool:
         """Delete the model from the catalog. If the model does not exist, 
return false.
@@ -344,7 +344,7 @@ class GenericModelCatalog(BaseSchemaCatalog):
         )
         model_response = ModelResponse.from_json(resp.body, infer_missing=True)
         model_response.validate()
-        return GenericModel(model_response.model())
+        return GenericModel(model_response.model(), self.rest_client, 
model_full_ns)
 
     def alter_model_version(
         self, model_ident: NameIdentifier, version: int, *changes: 
ModelVersionChange
diff --git a/clients/client-python/gravitino/client/generic_schema.py 
b/clients/client-python/gravitino/client/generic_schema.py
new file mode 100644
index 0000000000..eaf375dcf1
--- /dev/null
+++ b/clients/client-python/gravitino/client/generic_schema.py
@@ -0,0 +1,93 @@
+# 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 typing import Dict
+
+from gravitino.api.audit import Audit
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.metadata_objects import MetadataObjects
+from gravitino.api.schema import Schema
+from gravitino.api.tag.supports_tags import SupportsTags
+from gravitino.api.tag.tag import Tag
+from gravitino.client.metadata_object_tag_operations import 
MetadataObjectTagOperations
+from gravitino.dto.schema_dto import SchemaDTO
+from gravitino.utils.http_client import HTTPClient
+
+
+class GenericSchema(
+    Schema,
+    SupportsTags,
+):
+    def __init__(
+        self,
+        schema_dto: SchemaDTO,
+        rest_client: HTTPClient,
+        metalake: str,
+        catalog: str,
+    ) -> None:
+        self._schema_dto = schema_dto
+        metadata_object: MetadataObject = MetadataObjects.of(
+            [
+                catalog,
+                schema_dto.name(),
+            ],
+            MetadataObject.Type.SCHEMA,
+        )
+        self._metadata_object_tag_operations = MetadataObjectTagOperations(
+            metalake,
+            metadata_object,
+            rest_client,
+        )
+
+    def __eq__(self, value: object) -> bool:
+        if not isinstance(value, GenericSchema):
+            return False
+
+        return self._schema_dto == value._schema_dto
+
+    def __hash__(self) -> int:
+        return hash(self._schema_dto)
+
+    def name(self) -> str:
+        return self._schema_dto.name()
+
+    def comment(self) -> str | None:
+        return self._schema_dto.comment()
+
+    def properties(self) -> Dict[str, str]:
+        return self._schema_dto.properties()
+
+    def audit_info(self) -> Audit:
+        return self._schema_dto.audit_info()
+
+    def list_tags(self) -> list[str]:
+        return self._metadata_object_tag_operations.list_tags()
+
+    def list_tags_info(self) -> list[Tag]:
+        return self._metadata_object_tag_operations.list_tags_info()
+
+    def get_tag(self, name: str) -> Tag:
+        return self._metadata_object_tag_operations.get_tag(name)
+
+    def associate_tags(
+        self, tags_to_add: list[str], tags_to_remove: list[str]
+    ) -> list[str]:
+        return self._metadata_object_tag_operations.associate_tags(
+            tags_to_add, tags_to_remove
+        )
diff --git 
a/clients/client-python/gravitino/client/metadata_object_tag_operations.py 
b/clients/client-python/gravitino/client/metadata_object_tag_operations.py
new file mode 100644
index 0000000000..499486019c
--- /dev/null
+++ b/clients/client-python/gravitino/client/metadata_object_tag_operations.py
@@ -0,0 +1,131 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from __future__ import annotations
+
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.tag.supports_tags import SupportsTags
+from gravitino.api.tag.tag import Tag
+from gravitino.client.generic_tag import GenericTag
+from gravitino.dto.requests.tag_associate_request import TagsAssociateRequest
+from gravitino.dto.responses.tag_response import (
+    TagListResponse,
+    TagNamesListResponse,
+    TagResponse,
+)
+from gravitino.exceptions.handlers.tag_error_handler import TAG_ERROR_HANDLER
+from gravitino.rest.rest_utils import encode_string
+from gravitino.utils.http_client import HTTPClient
+from gravitino.utils.precondition import Precondition
+from gravitino.utils.string_utils import StringUtils
+
+
+class MetadataObjectTagOperations(SupportsTags):
+    """
+    The implementation of SupportsTags. This helper is composed into metadata 
objects,
+    including catalog, schema, table, column, fileset, and topic, to provide 
tag
+    operations for these objects.
+    """
+
+    TAG_REQUEST_PATH = "api/metalakes/{}/objects/{}/{}/tags"
+
+    def __init__(
+        self,
+        metalake_name: str,
+        metadata_object: MetadataObject,
+        rest_client: HTTPClient,
+    ):
+        super().__init__()
+        self.metalake_name = metalake_name
+        self.metadata_object = metadata_object
+        self.rest_client = rest_client
+        self.tag_request_path = 
MetadataObjectTagOperations.TAG_REQUEST_PATH.format(
+            encode_string(metalake_name),
+            metadata_object.type().name.lower(),
+            encode_string(metadata_object.full_name()),
+        )
+
+    def list_tags(self) -> list[str]:
+        response = self.rest_client.get(
+            self.tag_request_path,
+            params={},
+            error_handler=TAG_ERROR_HANDLER,
+        )
+        list_tags_resp = TagNamesListResponse.from_json(
+            response.body, infer_missing=True
+        )
+        list_tags_resp.validate()
+
+        return list_tags_resp.tag_names()
+
+    def list_tags_info(self) -> list[Tag]:
+        response = self.rest_client.get(
+            self.tag_request_path,
+            params={"details": "true"},
+            error_handler=TAG_ERROR_HANDLER,
+        )
+
+        list_info_resp = TagListResponse.from_json(response.body, 
infer_missing=True)
+        list_info_resp.validate()
+
+        return [
+            GenericTag(
+                self.metalake_name,
+                tag_dto,
+                self.rest_client,
+            )
+            for tag_dto in list_info_resp.tags()
+        ]
+
+    def get_tag(self, name: str) -> Tag:
+        Precondition.check_argument(
+            StringUtils.is_not_blank(name), "Tag name must not be null or 
empty"
+        )
+
+        response = self.rest_client.get(
+            f"{self.tag_request_path}/{encode_string(name)}",
+            params={},
+            error_handler=TAG_ERROR_HANDLER,
+        )
+
+        get_resp = TagResponse.from_json(response.body, infer_missing=True)
+        get_resp.validate()
+
+        return GenericTag(
+            self.metalake_name,
+            get_resp.tag(),
+            self.rest_client,
+        )
+
+    def associate_tags(
+        self, tags_to_add: list[str], tags_to_remove: list[str]
+    ) -> list[str]:
+        associate_request = TagsAssociateRequest(tags_to_add, tags_to_remove)
+        associate_request.validate()
+
+        response = self.rest_client.post(
+            self.tag_request_path,
+            json=associate_request,
+            error_handler=TAG_ERROR_HANDLER,
+        )
+
+        associate_resp = TagNamesListResponse.from_json(
+            response.body, infer_missing=True
+        )
+        associate_resp.validate()
+
+        return associate_resp.tag_names()
diff --git a/clients/client-python/gravitino/client/relational_table.py 
b/clients/client-python/gravitino/client/relational_table.py
index 86c62cbdd3..aa1feafdb1 100644
--- a/clients/client-python/gravitino/client/relational_table.py
+++ b/clients/client-python/gravitino/client/relational_table.py
@@ -18,6 +18,8 @@
 from typing import Optional, cast
 
 from gravitino.api.audit import Audit
+from gravitino.api.metadata_object import MetadataObject
+from gravitino.api.metadata_objects import MetadataObjects
 from gravitino.api.rel.column import Column
 from gravitino.api.rel.expressions.distributions.distribution import 
Distribution
 from gravitino.api.rel.expressions.sorts.sort_order import SortOrder
@@ -25,7 +27,10 @@ from gravitino.api.rel.expressions.transforms.transform 
import Transform
 from gravitino.api.rel.indexes.index import Index
 from gravitino.api.rel.partitions.partition import Partition
 from gravitino.api.rel.table import Table
+from gravitino.api.tag.supports_tags import SupportsTags
+from gravitino.api.tag.tag import Tag
 from gravitino.client.generic_column import GenericColumn
+from gravitino.client.metadata_object_tag_operations import 
MetadataObjectTagOperations
 from gravitino.dto.rel.partitions.partition_dto import PartitionDTO
 from gravitino.dto.rel.table_dto import TableDTO
 from gravitino.dto.requests.add_partitions_request import AddPartitionsRequest
@@ -44,7 +49,10 @@ from gravitino.rest.rest_utils import encode_string
 from gravitino.utils import HTTPClient
 
 
-class RelationalTable(Table):
+class RelationalTable(
+    Table,
+    SupportsTags,
+):
     """Represents a relational table."""
 
     def __init__(
@@ -53,6 +61,17 @@ class RelationalTable(Table):
         self._namespace = namespace
         self._table = cast(Table, DTOConverters.from_dto(table_dto))
         self._rest_client = rest_client
+        table_object = MetadataObjects.parse(
+            RelationalTable.table_full_name(namespace, table_dto.name()),
+            MetadataObject.Type.TABLE,
+        )
+        self._object_tag_operations = MetadataObjectTagOperations(
+            namespace.level(0), table_object, rest_client
+        )
+
+    @staticmethod
+    def table_full_name(table_ns: Namespace, table_name: str) -> str:
+        return f"{table_ns.level(1)}.{table_ns.level(2)}.{table_name}"
 
     def name(self) -> str:
         return self._table.name()
@@ -213,3 +232,17 @@ class RelationalTable(Table):
         )
         partition_list_resp.validate()
         return partition_list_resp.get_partitions()[0]
+
+    def list_tags(self) -> list[str]:
+        return self._object_tag_operations.list_tags()
+
+    def list_tags_info(self) -> list[Tag]:
+        return self._object_tag_operations.list_tags_info()
+
+    def get_tag(self, name: str) -> Tag:
+        return self._object_tag_operations.get_tag(name)
+
+    def associate_tags(
+        self, tags_to_add: list[str], tags_to_remove: list[str]
+    ) -> list[str]:
+        return self._object_tag_operations.associate_tags(tags_to_add, 
tags_to_remove)
diff --git a/clients/client-python/gravitino/dto/model_dto.py 
b/clients/client-python/gravitino/dto/model_dto.py
index cde6d5d8dc..57d1297be6 100644
--- a/clients/client-python/gravitino/dto/model_dto.py
+++ b/clients/client-python/gravitino/dto/model_dto.py
@@ -15,7 +15,7 @@
 # specific language governing permissions and limitations
 # under the License.
 from dataclasses import dataclass, field
-from typing import Optional, Dict
+from typing import Dict, Optional
 
 from dataclasses_json import DataClassJsonMixin, config
 
@@ -35,6 +35,32 @@ class ModelDTO(Model, DataClassJsonMixin):
     _latest_version: int = field(metadata=config(field_name="latestVersion"))
     _audit: AuditDTO = field(default=None, metadata=config(field_name="audit"))
 
+    def __eq__(self, value: object) -> bool:
+        if not isinstance(value, ModelDTO):
+            return False
+
+        return (
+            self._name == value._name
+            and self._comment == value._comment
+            and self._properties == value._properties
+            and self._latest_version == value._latest_version
+            and self._audit == value._audit
+        )
+
+    def __hash__(self) -> int:
+        properties_tuple = (
+            () if self._properties is None else 
tuple(sorted(self._properties.items()))
+        )
+        return hash(
+            (
+                self._name,
+                self._comment,
+                properties_tuple,
+                self._latest_version,
+                self._audit,
+            )
+        )
+
     def name(self) -> str:
         return self._name
 
diff --git 
a/clients/client-python/gravitino/dto/requests/tag_associate_request.py 
b/clients/client-python/gravitino/dto/requests/tag_associate_request.py
new file mode 100644
index 0000000000..6b78b0c642
--- /dev/null
+++ b/clients/client-python/gravitino/dto/requests/tag_associate_request.py
@@ -0,0 +1,71 @@
+# 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.rest.rest_message import RESTRequest
+from gravitino.utils.precondition import Precondition
+from gravitino.utils.string_utils import StringUtils
+
+
+@dataclass_json
+@dataclass
+class TagsAssociateRequest(RESTRequest):
+    """
+    Represents a request to associate tags.
+    """
+
+    _tags_to_add: list[str] = field(metadata=config(field_name="tagsToAdd"))
+    _tags_to_remove: list[str] = 
field(metadata=config(field_name="tagsToRemove"))
+
+    @property
+    def tags_to_add(self) -> list[str]:
+        """
+        Gets the tags to add.
+        """
+        return self._tags_to_add
+
+    @property
+    def tags_to_remove(self) -> list[str]:
+        """
+        Gets the tags to remove.
+        """
+        return self._tags_to_remove
+
+    def validate(self) -> None:
+        """
+        Validates the request.
+        """
+        Precondition.check_argument(
+            self._tags_to_add is not None or self._tags_to_remove is not None,
+            "tagsToAdd and tagsToRemove cannot both be null",
+        )
+
+        self._validate_tags(self._tags_to_add, "tagsToAdd")
+        self._validate_tags(self._tags_to_remove, "tagsToRemove")
+
+    def _validate_tags(self, tags: list[str] | None, field_name: str) -> None:
+        if tags is None:
+            return
+
+        Precondition.check_argument(
+            all(StringUtils.is_not_blank(tag) for tag in tags),
+            f"{field_name} must not contain null or empty tag names",
+        )
diff --git a/clients/client-python/gravitino/dto/schema_dto.py 
b/clients/client-python/gravitino/dto/schema_dto.py
index 499dac4841..cd79ac16a7 100644
--- a/clients/client-python/gravitino/dto/schema_dto.py
+++ b/clients/client-python/gravitino/dto/schema_dto.py
@@ -16,9 +16,9 @@
 # under the License.
 
 from dataclasses import dataclass, field
-from typing import Optional, Dict
+from typing import Dict, Optional
 
-from dataclasses_json import config
+from dataclasses_json import DataClassJsonMixin, config
 
 from gravitino.api.audit import Audit
 from gravitino.api.schema import Schema
@@ -26,7 +26,7 @@ from gravitino.dto.audit_dto import AuditDTO
 
 
 @dataclass
-class SchemaDTO(Schema):
+class SchemaDTO(Schema, DataClassJsonMixin):
     """Represents a Schema DTO (Data Transfer Object)."""
 
     _name: str = field(metadata=config(field_name="name"))
@@ -43,6 +43,30 @@ class SchemaDTO(Schema):
     _audit: AuditDTO = field(metadata=config(field_name="audit"))
     """The audit information of the Metalake DTO."""
 
+    def __eq__(self, value: object) -> bool:
+        if not isinstance(value, SchemaDTO):
+            return False
+
+        return (
+            self._name == value.name()
+            and self._comment == value.comment()
+            and self._properties == value.properties()
+            and self._audit == value.audit_info()
+        )
+
+    def __hash__(self) -> int:
+        properties_tuple = (
+            () if self._properties is None else 
tuple(sorted(self._properties.items()))
+        )
+        return hash(
+            (
+                self._name,
+                self._comment,
+                properties_tuple,
+                self._audit,
+            )
+        )
+
     def name(self) -> str:
         return self._name
 
diff --git 
a/clients/client-python/tests/unittests/client/test_metadata_object_tag_operations.py
 
b/clients/client-python/tests/unittests/client/test_metadata_object_tag_operations.py
new file mode 100644
index 0000000000..2e0b6f929f
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/client/test_metadata_object_tag_operations.py
@@ -0,0 +1,188 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import unittest
+from unittest.mock import patch
+
+from gravitino.api.metadata_objects import MetadataObject, MetadataObjects
+from gravitino.api.tag import Tag
+from gravitino.client.generic_tag import GenericTag
+from gravitino.client.metadata_object_tag_operations import 
MetadataObjectTagOperations
+from gravitino.dto.requests.tag_associate_request import TagsAssociateRequest
+from gravitino.dto.responses.tag_response import (
+    TagListResponse,
+    TagNamesListResponse,
+    TagResponse,
+)
+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 tests.unittests import mock_base
+
+
+class TestMetadataObjectTagOperations(unittest.TestCase):
+    REST_CLIENT = HTTPClient("http://localhost:8090";)
+    METALAKE_NAME = "demo_metalake"
+
+    def test_list_tag_request_path(self) -> None:
+        metadata_object = MetadataObjects.of(
+            ["catalog", "schema", "table"], MetadataObject.Type.TABLE
+        )
+        table_tag_request_path = 
MetadataObjectTagOperations.TAG_REQUEST_PATH.format(
+            encode_string(TestMetadataObjectTagOperations.METALAKE_NAME),
+            metadata_object.type().name.lower(),
+            encode_string(metadata_object.full_name()),
+        )
+        self.assertEqual(
+            
"api/metalakes/demo_metalake/objects/table/catalog.schema.table/tags",
+            table_tag_request_path,
+        )
+
+    def test_get_tag_request_path(self) -> None:
+        metadata_object = MetadataObjects.of(
+            ["catalog", "schema", "table"], MetadataObject.Type.TABLE
+        )
+        table_tag_request_path = 
MetadataObjectTagOperations.TAG_REQUEST_PATH.format(
+            encode_string(TestMetadataObjectTagOperations.METALAKE_NAME),
+            metadata_object.type().name.lower(),
+            encode_string(metadata_object.full_name()),
+        )
+        self.assertEqual(
+            
"api/metalakes/demo_metalake/objects/table/catalog.schema.table/tags/tag_name",
+            f"{table_tag_request_path}/tag_name",
+        )
+
+    def test_get_tag_from_metadata_object(self) -> None:
+        tag_operations = MetadataObjectTagOperations(
+            TestMetadataObjectTagOperations.METALAKE_NAME,
+            MetadataObjects.of(
+                ["catalog", "schema"],
+                MetadataObject.Type.SCHEMA,
+            ),
+            TestMetadataObjectTagOperations.REST_CLIENT,
+        )
+
+        tag = mock_base.build_tag_dto()
+        resp = TagResponse(0, tag)
+        json_str = resp.to_json()
+        mock_resp = mock_base.mock_http_response(json_str)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=mock_resp,
+        ) as mock_get:
+            retrieved_tag = tag_operations.get_tag(tag.name())
+            self.check_tag_equal(tag, retrieved_tag)
+            mock_get.assert_called_once_with(
+                
"api/metalakes/demo_metalake/objects/schema/catalog.schema/tags/tagA",
+                params={},
+                error_handler=TAG_ERROR_HANDLER,
+            )
+
+    def test_list_tags(self) -> None:
+        tag_operations = MetadataObjectTagOperations(
+            TestMetadataObjectTagOperations.METALAKE_NAME,
+            MetadataObjects.of(
+                ["catalog", "schema", "table"],
+                MetadataObject.Type.TABLE,
+            ),
+            TestMetadataObjectTagOperations.REST_CLIENT,
+        )
+        json_str = TagNamesListResponse(0, ["tagA", "tagB"]).to_json()
+        mock_resp = mock_base.mock_http_response(json_str)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=mock_resp,
+        ) as mock_get:
+            tags = tag_operations.list_tags()
+
+            self.assertEqual(["tagA", "tagB"], tags)
+            mock_get.assert_called_once_with(
+                
"api/metalakes/demo_metalake/objects/table/catalog.schema.table/tags",
+                params={},
+                error_handler=TAG_ERROR_HANDLER,
+            )
+
+    def test_list_tags_info(self) -> None:
+        tag_operations = MetadataObjectTagOperations(
+            TestMetadataObjectTagOperations.METALAKE_NAME,
+            MetadataObjects.of(
+                ["catalog", "schema", "table"],
+                MetadataObject.Type.TABLE,
+            ),
+            TestMetadataObjectTagOperations.REST_CLIENT,
+        )
+        tag1 = mock_base.build_tag_dto()
+        tag2 = mock_base.build_tag_dto(name="tagB", comment="commentB")
+        json_str = TagListResponse(0, [tag1, tag2]).to_json()
+        mock_resp = mock_base.mock_http_response(json_str)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.get",
+            return_value=mock_resp,
+        ) as mock_get:
+            tags = tag_operations.list_tags_info()
+
+            self.assertEqual(2, len(tags))
+            self.assertTrue(all(isinstance(tag, GenericTag) for tag in tags))
+            mock_get.assert_called_once_with(
+                
"api/metalakes/demo_metalake/objects/table/catalog.schema.table/tags",
+                params={"details": "true"},
+                error_handler=TAG_ERROR_HANDLER,
+            )
+
+    def test_associate_tags(self) -> None:
+        tag_operations = MetadataObjectTagOperations(
+            TestMetadataObjectTagOperations.METALAKE_NAME,
+            MetadataObjects.of(
+                ["catalog", "schema", "table"],
+                MetadataObject.Type.TABLE,
+            ),
+            TestMetadataObjectTagOperations.REST_CLIENT,
+        )
+        json_str = TagNamesListResponse(0, ["tagA", "tagC"]).to_json()
+        mock_resp = mock_base.mock_http_response(json_str)
+
+        with patch(
+            "gravitino.utils.http_client.HTTPClient.post",
+            return_value=mock_resp,
+        ) as mock_post:
+            tags = tag_operations.associate_tags(["tagA"], ["tagB"])
+
+            self.assertEqual(["tagA", "tagC"], tags)
+            mock_post.assert_called_once()
+            call_args = mock_post.call_args
+            self.assertEqual(
+                
"api/metalakes/demo_metalake/objects/table/catalog.schema.table/tags",
+                call_args.args[0],
+            )
+
+            param = TagsAssociateRequest(["tagA"], ["tagB"])
+
+            mock_post.assert_called_once_with(
+                
"api/metalakes/demo_metalake/objects/table/catalog.schema.table/tags",
+                json=param,
+                error_handler=TAG_ERROR_HANDLER,
+            )
+
+    def check_tag_equal(self, left: Tag, right: Tag) -> None:
+        self.assertEqual(left.name(), right.name())
+        self.assertEqual(left.comment(), right.comment())
+        self.assertEqual(left.properties(), right.properties())
diff --git 
a/clients/client-python/tests/unittests/dto/requests/test_tags_associate_request.py
 
b/clients/client-python/tests/unittests/dto/requests/test_tags_associate_request.py
new file mode 100644
index 0000000000..2a208aa95c
--- /dev/null
+++ 
b/clients/client-python/tests/unittests/dto/requests/test_tags_associate_request.py
@@ -0,0 +1,61 @@
+# 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.tag_associate_request import TagsAssociateRequest
+from gravitino.exceptions.base import IllegalArgumentException
+
+
+class TestTagsAssociateRequest(unittest.TestCase):
+    def test_create_request(self) -> None:
+        request = TagsAssociateRequest(
+            ["tag_to_add_1", "tag_to_add_2"], ["tag_to_remove_1", 
"tag_to_remove_2"]
+        )
+        json_str = _json.dumps(
+            {
+                "tagsToAdd": ["tag_to_add_1", "tag_to_add_2"],
+                "tagsToRemove": ["tag_to_remove_1", "tag_to_remove_2"],
+            }
+        )
+
+        self.assertEqual(json_str, request.to_json())
+        deserialized_request = TagsAssociateRequest.from_json(json_str)
+
+        self.assertTrue(isinstance(deserialized_request, TagsAssociateRequest))
+        self.assertEqual(
+            ["tag_to_add_1", "tag_to_add_2"], deserialized_request.tags_to_add
+        )
+        self.assertEqual(
+            ["tag_to_remove_1", "tag_to_remove_2"], 
deserialized_request.tags_to_remove
+        )
+
+    def test_associate_request_validate(self) -> None:
+        invalid_request1 = TagsAssociateRequest(
+            None, None
+        )  # pyright: ignore[reportArgumentType]
+        invalid_request2 = TagsAssociateRequest(
+            ["tag_to_add_1", " "], ["tag_to_remove_1", "tag_to_remove_2"]
+        )
+
+        with self.assertRaises(IllegalArgumentException):
+            invalid_request1.validate()
+
+        with self.assertRaises(IllegalArgumentException):
+            invalid_request2.validate()
diff --git a/clients/client-python/tests/unittests/mock_base.py 
b/clients/client-python/tests/unittests/mock_base.py
index 8ce25fad67..3f1cd15494 100644
--- a/clients/client-python/tests/unittests/mock_base.py
+++ b/clients/client-python/tests/unittests/mock_base.py
@@ -29,6 +29,7 @@ from gravitino.client.generic_model_catalog import 
GenericModelCatalog
 from gravitino.dto.audit_dto import AuditDTO
 from gravitino.dto.fileset_dto import FilesetDTO
 from gravitino.dto.metalake_dto import MetalakeDTO
+from gravitino.dto.model_dto import ModelDTO
 from gravitino.dto.schema_dto import SchemaDTO
 from gravitino.dto.tag_dto import TagDTO
 from gravitino.namespace import Namespace
@@ -36,6 +37,71 @@ from gravitino.utils import Response
 from gravitino.utils.http_client import HTTPClient
 
 
+def build_schema_dto(
+    name: str = "demo_schema",
+    comment: str = "This is a demo schema.",
+    properties: tp.Optional[dict[str, str]] = None,
+) -> SchemaDTO:
+    """
+    Build a schema DTO for testing.
+
+    Args:
+        name (str, optional): The name of the schema. Defaults to 
"demo_schema".
+        comment (str, optional): The comment for the schema. Defaults to "This 
is a demo model.".
+        properties (tp.Optional[dict[str, str]], optional): The properties for 
the schema.
+
+    Returns:
+        SchemaDTO: The built schema DTO.
+    """
+    if properties is None:
+        properties = {
+            "key1": "value1",
+            "key2": "value2",
+        }
+    return SchemaDTO.from_dict(
+        {
+            "name": name,
+            "comment": comment,
+            "properties": properties,
+            "audit": build_audit_info().to_dict(),
+        }
+    )
+
+
+def build_model_dto(
+    name: str = "demo_model",
+    comment: str = "This is a demo model.",
+    properties: tp.Optional[dict[str, str]] = None,
+    latest_version: int = 1,
+) -> ModelDTO:
+    """
+    Build a model DTO for testing.
+
+    Args:
+        name (str, optional): The name of the model. Defaults to "demo_model".
+        comment (str, optional): The comment for the model. Defaults to "This 
is a demo model.".
+        properties (tp.Optional[dict[str, str]], optional): The properties for 
the model.
+        latest_version (int, optional): The latest version of the model. 
Defaults to 1.
+
+    Returns:
+        ModelDTO: The built model DTO.
+    """
+    if properties is None:
+        properties = {
+            "key1": "value1",
+            "key2": "value2",
+        }
+    return ModelDTO.from_dict(
+        {
+            "name": name,
+            "comment": comment,
+            "properties": properties,
+            "latestVersion": latest_version,
+            "audit": build_audit_info().to_dict(),
+        }
+    )
+
+
 def build_tag_dto(
     name: str = "tagA",
     comment: str = "commentA",
diff --git a/clients/client-python/tests/unittests/test_generic_column.py 
b/clients/client-python/tests/unittests/test_generic_column.py
index 97fab80a23..bbf0965291 100644
--- a/clients/client-python/tests/unittests/test_generic_column.py
+++ b/clients/client-python/tests/unittests/test_generic_column.py
@@ -20,6 +20,7 @@ import unittest
 from gravitino.api.rel.column import Column
 from gravitino.api.rel.expressions.literals.literals import Literals
 from gravitino.api.rel.types.types import Types
+from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.client.generic_column import GenericColumn
 from gravitino.utils import HTTPClient
 
@@ -112,3 +113,34 @@ class TestGenericColumn(unittest.TestCase):
         )
         self.assertEqual(TestGenericColumn._generic_column, 
another_generic_column)
         self.assertFalse(another_generic_column == "invalid_generic_column")
+
+    def test_extends_supports_tags_class(self) -> None:
+        another_column = Column.of(
+            name="test_column",
+            data_type=Types.StringType.get(),
+            nullable=False,
+            default_value=Literals.string_literal(value="test"),
+        )
+
+        another_generic_column = GenericColumn(
+            column=another_column,
+            rest_client=HTTPClient("http://localhost:8090";),
+            metalake="metalake_demo",
+            catalog="relational_catalog",
+            schema="test_schema",
+            table="test_table",
+        )
+        self.assertTrue(
+            issubclass(
+                GenericColumn,
+                SupportsTags,
+            )
+        )
+        expected_methods = ["list_tags", "list_tags_info", "get_tag", 
"associate_tags"]
+
+        self.assertTrue(
+            all(
+                callable(getattr(another_generic_column, method, None))
+                for method in expected_methods
+            )
+        )
diff --git a/clients/client-python/tests/unittests/test_generic_model.py 
b/clients/client-python/tests/unittests/test_generic_model.py
new file mode 100644
index 0000000000..c674530751
--- /dev/null
+++ b/clients/client-python/tests/unittests/test_generic_model.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.
+
+import unittest
+
+from gravitino.api.tag.supports_tags import SupportsTags
+from gravitino.client.generic_model import GenericModel
+from gravitino.name_identifier import NameIdentifier
+from gravitino.namespace import Namespace
+from gravitino.utils.http_client import HTTPClient
+from tests.unittests.mock_base import build_model_dto
+
+
+class TestGenericModel(unittest.TestCase):
+    _rest_client = HTTPClient("http://localhost:8080";)
+    _model_ident: NameIdentifier = NameIdentifier.of(
+        "demo_metalake", "demo_catalog", "demo_schema", "demo_model"
+    )
+
+    def test_equal_and_hash(self) -> None:
+        generic_model = GenericModel(
+            build_model_dto(),
+            TestGenericModel._rest_client,
+            TestGenericModel._model_ident.namespace(),
+        )
+
+        generic_model2 = GenericModel(
+            build_model_dto(),
+            TestGenericModel._rest_client,
+            Namespace.of("demo_metalake", "demo_catalog", "demo_schema"),
+        )
+
+        self.assertEqual(generic_model, generic_model2)
+        self.assertEqual(hash(generic_model), hash(generic_model2))
+
+    def test_extends_supports_tags_class(self) -> None:
+        genenric_model = GenericModel(
+            build_model_dto(),
+            TestGenericModel._rest_client,
+            TestGenericModel._model_ident.namespace(),
+        )
+
+        self.assertTrue(
+            issubclass(
+                GenericModel,
+                SupportsTags,
+            )
+        )
+        expected_methods = ["list_tags", "list_tags_info", "get_tag", 
"associate_tags"]
+
+        self.assertTrue(
+            all(
+                callable(getattr(genenric_model, method, None))
+                for method in expected_methods
+            )
+        )
diff --git a/clients/client-python/tests/unittests/test_generic_schema.py 
b/clients/client-python/tests/unittests/test_generic_schema.py
new file mode 100644
index 0000000000..82645b63f2
--- /dev/null
+++ b/clients/client-python/tests/unittests/test_generic_schema.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.
+
+import unittest
+
+from gravitino.api.tag.supports_tags import SupportsTags
+from gravitino.client.generic_schema import GenericSchema
+from gravitino.utils.http_client import HTTPClient
+from tests.unittests.mock_base import build_schema_dto
+
+
+class TestGenericSchema(unittest.TestCase):
+    _rest_client = HTTPClient("http://localhost:8080";)
+
+    def test_equal_and_hash(self) -> None:
+        schema_dto1 = build_schema_dto()
+        schema_dto2 = build_schema_dto()
+
+        generic_schema1 = GenericSchema(
+            schema_dto1,
+            TestGenericSchema._rest_client,
+            "demo_metalake",
+            "demo_catalog",
+        )
+        generic_schema2 = GenericSchema(
+            schema_dto2,
+            TestGenericSchema._rest_client,
+            "demo_metalake",
+            "demo_catalog",
+        )
+
+        self.assertEqual(generic_schema1, generic_schema2)
+        self.assertEqual(hash(generic_schema1), hash(generic_schema2))
+
+    def test_extends_supports_tags_class(self) -> None:
+        generic_schema = GenericSchema(
+            build_schema_dto(),
+            TestGenericSchema._rest_client,
+            "demo_metalake",
+            "demo_catalog",
+        )
+
+        self.assertTrue(
+            issubclass(
+                GenericSchema,
+                SupportsTags,
+            )
+        )
+        expected_methods = ["list_tags", "list_tags_info", "get_tag", 
"associate_tags"]
+
+        self.assertTrue(
+            all(
+                callable(getattr(generic_schema, method, None))
+                for method in expected_methods
+            )
+        )
diff --git a/clients/client-python/tests/unittests/test_relational_table.py 
b/clients/client-python/tests/unittests/test_relational_table.py
index aa2bae4bb7..f4c34da00d 100644
--- a/clients/client-python/tests/unittests/test_relational_table.py
+++ b/clients/client-python/tests/unittests/test_relational_table.py
@@ -28,6 +28,7 @@ from gravitino.api.rel.expressions.sorts.sort_direction 
import SortDirection
 from gravitino.api.rel.expressions.transforms.transforms import Transforms
 from gravitino.api.rel.indexes.index import Index
 from gravitino.api.rel.partitions.partitions import Partitions
+from gravitino.api.tag.supports_tags import SupportsTags
 from gravitino.client.generic_column import GenericColumn
 from gravitino.client.relational_table import RelationalTable
 from gravitino.dto.rel.partitions.json_serdes.partition_dto_serdes import (
@@ -326,3 +327,24 @@ class TestRelationalTable(unittest.TestCase):
     def test_get_properties(self):
         properties = self.relational_table.properties()
         self.assertDictEqual(properties, {"format": "ORC"})
+
+    def test_extends_supports_tags_class(self) -> None:
+        table_dto = 
TableDTO.from_json(TestRelationalTable.TABLE_DTO_JSON_STRING)
+        namespace = Namespace.of("metalake_demo", "test_catalog", 
"test_schema")
+        rest_client = HTTPClient("http://localhost:8090";)
+        relational_table = RelationalTable(namespace, table_dto, rest_client)
+
+        self.assertTrue(
+            issubclass(
+                RelationalTable,
+                SupportsTags,
+            )
+        )
+        expected_methods = ["list_tags", "list_tags_info", "get_tag", 
"associate_tags"]
+
+        self.assertTrue(
+            all(
+                callable(getattr(relational_table, method, None))
+                for method in expected_methods
+            )
+        )

Reply via email to