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