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