This is an automated email from the ASF dual-hosted git repository.
roryqi 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 ac4eff7e37 [#12441] feat(client-python): Support tag assignment values
(#12442)
ac4eff7e37 is described below
commit ac4eff7e37a4ebfb1afb7d3902364342e466b67c
Author: roryqi <[email protected]>
AuthorDate: Wed Aug 19 10:32:45 2026 +0800
[#12441] feat(client-python): Support tag assignment values (#12442)
### What changes were proposed in this pull request?
This PR adds Python client support for tag assignment values:
- Add `allowed_values` support to tag creation and tag DTOs.
- Add assignment-context values to detailed tag models.
- Add tag-value pair request support for object tag assignment.
- Add `assign_tags(...)` while keeping `associate_tags(...)` compatible.
- Add value-filtered associated-object lookup through
`tag.associated_objects().objects(value=...)`.
- Add unit tests for DTO serialization, client request paths, and new
APIs.
### Why are the changes needed?
Python client APIs need to match the tag assignment values REST design
so users can create tags with allowed values, assign values such as
`data_domain=finance` to metadata objects, and find objects through the
existing tag associated-object lookup API.
Fix: #12441
### Does this PR introduce _any_ user-facing change?
Yes. The Python client adds:
- `create_tag(..., allowed_values=None)`
- `Tag.allowed_values()`
- `Tag.assignment_values()`
- `SupportsTags.assign_tags(...)`
- `tag.associated_objects().objects(value=None)`
Existing `associate_tags(...)` remains available.
### How was this patch tested?
- `PYTHONPATH=/tmp/gravitino-python-tag-values-pr/clients/client-python
python -m unittest tests.unittests.test_generic_tag
tests.unittests.dto.requests.test_tags_associate_request
tests.unittests.dto.requests.test_tag_create_request
tests.unittests.dto.test_tag_dto tests.unittests.test_generic_view
tests.unittests.test_generic_function
tests.unittests.client.test_metadata_object_tag_operations
tests.unittests.test_tag_api`
- `PYTHONPATH=/tmp/gravitino-python-tag-values-pr/clients/client-python
python -m unittest discover tests/unittests`
- `black --check ./gravitino ./tests/unittests ./scripts`
- `pylint <changed files>`
Also ran `./gradlew :clients:client-python:test`; `black` passed, but
the full integration test task failed due to existing environment
issues: Python client version `2.0.0.dev0` was higher than server
`1.3.1-SNAPSHOT`, and Docker image `apache/gravitino-ci:hive-0.1.13` was
missing.
---
.../gravitino/api/tag/supports_tags.py | 20 +++
clients/client-python/gravitino/api/tag/tag.py | 28 +++-
.../gravitino/api/tag/tag_operations.py | 2 +
.../gravitino/client/base_schema_catalog.py | 7 +
.../gravitino/client/generic_column.py | 7 +
.../gravitino/client/generic_fileset.py | 7 +
.../gravitino/client/generic_function.py | 8 ++
.../gravitino/client/generic_model.py | 7 +
.../gravitino/client/generic_schema.py | 9 ++
.../client-python/gravitino/client/generic_tag.py | 40 +++++-
.../client-python/gravitino/client/generic_view.py | 7 +
.../gravitino/client/gravitino_client.py | 7 +-
.../gravitino/client/gravitino_metalake.py | 4 +-
.../client/metadata_object_tag_operations.py | 41 +++++-
.../gravitino/client/relational_table.py | 7 +
.../gravitino/dto/requests/__init__.py | 8 ++
.../dto/requests/tag_associate_request.py | 160 ++++++++++++++++++---
.../gravitino/dto/requests/tag_create_request.py | 18 +++
clients/client-python/gravitino/dto/tag_dto.py | 53 ++++++-
.../client/test_metadata_object_tag_operations.py | 105 +++++++++++++-
.../dto/requests/test_tag_create_request.py | 11 ++
.../dto/requests/test_tags_associate_request.py | 98 +++++++++++--
.../tests/unittests/dto/test_tag_dto.py | 80 ++++++++++-
clients/client-python/tests/unittests/mock_base.py | 9 +-
.../tests/unittests/test_generic_function.py | 8 +-
.../tests/unittests/test_generic_tag.py | 71 ++++++++-
.../tests/unittests/test_generic_view.py | 8 +-
.../client-python/tests/unittests/test_tag_api.py | 64 ++++++++-
28 files changed, 828 insertions(+), 66 deletions(-)
diff --git a/clients/client-python/gravitino/api/tag/supports_tags.py
b/clients/client-python/gravitino/api/tag/supports_tags.py
index f1e9a20510..2e926fe1eb 100644
--- a/clients/client-python/gravitino/api/tag/supports_tags.py
+++ b/clients/client-python/gravitino/api/tag/supports_tags.py
@@ -20,6 +20,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from gravitino.api.tag.tag import Tag
+from gravitino.exceptions.base import UnsupportedOperationException
class SupportsTags(ABC):
@@ -61,6 +62,25 @@ class SupportsTags(ABC):
"""
pass
+ def assign_tags(
+ self,
+ tags_to_add: list[str | dict[str, str | None]] | None = None,
+ tags_to_remove: list[str | dict[str, str | None]] | None = None,
+ ) -> list[str]:
+ """Assign tag-value pairs to the specific object.
+
+ Args:
+ tags_to_add: The tag-value pairs to be added to the object.
+ tags_to_remove: The tag-value pairs to be removed from the object.
+
+ Raises:
+ UnsupportedOperationException: The assign_tags method is not
supported.
+
+ Returns:
+ list[str]: The tag names directly associated with the object after
assignment.
+ """
+ raise UnsupportedOperationException("The assign_tags method is not
supported.")
+
@abstractmethod
def associate_tags(
self, tags_to_add: list[str], tags_to_remove: list[str]
diff --git a/clients/client-python/gravitino/api/tag/tag.py
b/clients/client-python/gravitino/api/tag/tag.py
index 4845ebe764..b897e5e063 100644
--- a/clients/client-python/gravitino/api/tag/tag.py
+++ b/clients/client-python/gravitino/api/tag/tag.py
@@ -39,11 +39,14 @@ class AssociatedObjects(ABC):
return 0 if objects is None else len(objects)
@abstractmethod
- def objects(self) -> Optional[list[MetadataObject]]:
+ def objects(self, value: Optional[str] = None) ->
Optional[list[MetadataObject]]:
"""Get the associated objects.
+ Args:
+ value: The optional exact assignment value filter.
+
Returns:
- Optional[list[MetadataObject]]: The list of objects that are
associated with this tag..
+ Optional[list[MetadataObject]]: The list of objects that are
associated with this tag.
"""
pass
@@ -89,6 +92,22 @@ class Tag(Auditable):
"""
raise NotImplementedError()
+ def allowed_values(self) -> Optional[list[str]]:
+ """Get the allowed values for this tag.
+
+ Returns:
+ Optional[list[str]]: The allowed values, or None if values are
unrestricted.
+ """
+ return None
+
+ def assignment_values(self) -> Optional[list[str]]:
+ """Get assignment values when this tag is loaded from a metadata
object.
+
+ Returns:
+ Optional[list[str]]: The assignment values, or None if not
assignment-scoped.
+ """
+ return None
+
@abstractmethod
def inherited(self) -> Optional[bool]:
"""Check if the tag is inherited from a parent object or not.
@@ -131,10 +150,13 @@ class Tag(Auditable):
return 0 if (s := self.objects()) is None else len(s)
@abstractmethod
- def objects(self) -> list[MetadataObject]:
+ def objects(self, value: Optional[str] = None) -> list[MetadataObject]:
"""
Retrieve the list of objects that are associated with this tag.
+ Args:
+ value: The optional exact assignment value filter.
+
Raises:
NotImplementedError: if the method is not implemented.
diff --git a/clients/client-python/gravitino/api/tag/tag_operations.py
b/clients/client-python/gravitino/api/tag/tag_operations.py
index 7089564705..1ce28dfd84 100644
--- a/clients/client-python/gravitino/api/tag/tag_operations.py
+++ b/clients/client-python/gravitino/api/tag/tag_operations.py
@@ -77,6 +77,7 @@ class TagOperations(ABC):
tag_name: str,
comment: str,
properties: dict[str, str],
+ allowed_values: list[str] | None = None,
) -> Tag:
"""
Create a new tag under a metalake.
@@ -89,6 +90,7 @@ class TagOperations(ABC):
tag_name (str): The name of the tag.
comment (str): The comment of the tag.
properties (dict[str, str]): The properties of the tag.
+ allowed_values (list[str] | None): The allowed assignment values.
Returns:
Tag: The tag information.
diff --git a/clients/client-python/gravitino/client/base_schema_catalog.py
b/clients/client-python/gravitino/client/base_schema_catalog.py
index a2cbae094b..cda7b933b1 100644
--- a/clients/client-python/gravitino/client/base_schema_catalog.py
+++ b/clients/client-python/gravitino/client/base_schema_catalog.py
@@ -363,6 +363,13 @@ class BaseSchemaCatalog(
def get_tag(self, name: str) -> Tag:
return self._object_tag_operations.get_tag(name)
+ def assign_tags(
+ self,
+ tags_to_add: list[str | dict[str, str | None]] | None = None,
+ tags_to_remove: list[str | dict[str, str | None]] | None = None,
+ ) -> list[str]:
+ return self._object_tag_operations.assign_tags(tags_to_add,
tags_to_remove)
+
def associate_tags(
self, tags_to_add: List[str], tags_to_remove: List[str]
) -> List[str]:
diff --git a/clients/client-python/gravitino/client/generic_column.py
b/clients/client-python/gravitino/client/generic_column.py
index dbb473aaa6..79ea04020f 100644
--- a/clients/client-python/gravitino/client/generic_column.py
+++ b/clients/client-python/gravitino/client/generic_column.py
@@ -90,6 +90,13 @@ class GenericColumn(Column, SupportsTags):
def get_tag(self, name: str) -> Tag:
return self._object_tag_operations.get_tag(name)
+ def assign_tags(
+ self,
+ tags_to_add: list[str | dict[str, str | None]] | None = None,
+ tags_to_remove: list[str | dict[str, str | None]] | None = None,
+ ) -> list[str]:
+ return self._object_tag_operations.assign_tags(tags_to_add,
tags_to_remove)
+
def associate_tags(
self, tags_to_add: list[str], tags_to_remove: list[str]
) -> list[str]:
diff --git a/clients/client-python/gravitino/client/generic_fileset.py
b/clients/client-python/gravitino/client/generic_fileset.py
index 1aa0be9685..f7c90e63c4 100644
--- a/clients/client-python/gravitino/client/generic_fileset.py
+++ b/clients/client-python/gravitino/client/generic_fileset.py
@@ -95,6 +95,13 @@ class GenericFileset(
def get_tag(self, name: str) -> Tag:
return self._object_tag_operations.get_tag(name)
+ def assign_tags(
+ self,
+ tags_to_add: list[str | dict[str, str | None]] | None = None,
+ tags_to_remove: list[str | dict[str, str | None]] | None = None,
+ ) -> list[str]:
+ return self._object_tag_operations.assign_tags(tags_to_add,
tags_to_remove)
+
def associate_tags(
self, tags_to_add: List[str], tags_to_remove: List[str]
) -> List[str]:
diff --git a/clients/client-python/gravitino/client/generic_function.py
b/clients/client-python/gravitino/client/generic_function.py
index 2ef6bdfd72..66faaa939f 100644
--- a/clients/client-python/gravitino/client/generic_function.py
+++ b/clients/client-python/gravitino/client/generic_function.py
@@ -98,6 +98,14 @@ class GenericFunction(Function, SupportsTags):
"""Get an associated tag by name."""
return self._object_tag_operations.get_tag(name)
+ def assign_tags(
+ self,
+ tags_to_add: list[str | dict[str, str | None]] | None = None,
+ tags_to_remove: list[str | dict[str, str | None]] | None = None,
+ ) -> list[str]:
+ """Assign or remove tag-value pairs for the function."""
+ return self._object_tag_operations.assign_tags(tags_to_add,
tags_to_remove)
+
def associate_tags(
self, tags_to_add: list[str], tags_to_remove: list[str]
) -> list[str]:
diff --git a/clients/client-python/gravitino/client/generic_model.py
b/clients/client-python/gravitino/client/generic_model.py
index 2cfbc87369..41aba4562b 100644
--- a/clients/client-python/gravitino/client/generic_model.py
+++ b/clients/client-python/gravitino/client/generic_model.py
@@ -80,6 +80,13 @@ class GenericModel(Model, SupportsTags):
def get_tag(self, name: str) -> Tag:
return self._model_tag_operations.get_tag(name)
+ def assign_tags(
+ self,
+ tags_to_add: list[str | dict[str, str | None]] | None = None,
+ tags_to_remove: list[str | dict[str, str | None]] | None = None,
+ ) -> list[str]:
+ return self._model_tag_operations.assign_tags(tags_to_add,
tags_to_remove)
+
def associate_tags(
self, tags_to_add: list[str], tags_to_remove: list[str]
) -> list[str]:
diff --git a/clients/client-python/gravitino/client/generic_schema.py
b/clients/client-python/gravitino/client/generic_schema.py
index 8301e6ab87..09c895dcf0 100644
--- a/clients/client-python/gravitino/client/generic_schema.py
+++ b/clients/client-python/gravitino/client/generic_schema.py
@@ -85,6 +85,15 @@ class GenericSchema(
def get_tag(self, name: str) -> Tag:
return self._metadata_object_tag_operations.get_tag(name)
+ def assign_tags(
+ self,
+ tags_to_add: list[str | dict[str, str | None]] | None = None,
+ tags_to_remove: list[str | dict[str, str | None]] | None = None,
+ ) -> list[str]:
+ return self._metadata_object_tag_operations.assign_tags(
+ tags_to_add, tags_to_remove
+ )
+
def associate_tags(
self, tags_to_add: list[str], tags_to_remove: list[str]
) -> list[str]:
diff --git a/clients/client-python/gravitino/client/generic_tag.py
b/clients/client-python/gravitino/client/generic_tag.py
index 0b865f7046..187c85a222 100644
--- a/clients/client-python/gravitino/client/generic_tag.py
+++ b/clients/client-python/gravitino/client/generic_tag.py
@@ -29,6 +29,7 @@ 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.precondition import Precondition
from gravitino.utils.http_client import Response
@@ -80,6 +81,22 @@ class GenericTag(Tag, Tag.AssociatedObjects):
"""
return self._tag_dto.properties()
+ def allowed_values(self) -> Optional[list[str]]:
+ """Get the allowed values for this tag.
+
+ Returns:
+ Optional[list[str]]: The allowed values, or None if values are
unrestricted.
+ """
+ return self._tag_dto.allowed_values()
+
+ def assignment_values(self) -> Optional[list[str]]:
+ """Get assignment values when this tag is loaded from a metadata
object.
+
+ Returns:
+ Optional[list[str]]: The assignment values, or None if not
assignment-scoped.
+ """
+ return self._tag_dto.assignment_values()
+
def inherited(self) -> Optional[bool]:
"""Check if the tag is inherited from a parent object or not.
@@ -113,10 +130,13 @@ class GenericTag(Tag, Tag.AssociatedObjects):
"""
return self
- def objects(self) -> list[MetadataObject]:
+ def objects(self, value: Optional[str] = None) -> list[MetadataObject]:
"""
Retrieve the list of objects that are associated with this tag.
+ Args:
+ value: The optional exact assignment value filter.
+
Returns:
list[MetadataObject]: The list of objects that are associated with
this tag.
"""
@@ -125,7 +145,14 @@ class GenericTag(Tag, Tag.AssociatedObjects):
encode_string(self.name()),
)
- response = self.get_response(url, TAG_ERROR_HANDLER)
+ if value is None:
+ response = self.get_response(url, TAG_ERROR_HANDLER)
+ else:
+ Precondition.check_argument(
+ value.strip() != "" and len(value) <= 256,
+ "value must not be empty or longer than 256 characters",
+ )
+ response = self.get_response(url, TAG_ERROR_HANDLER, {"value":
value})
objects_resp = MetadataObjectListResponse.from_json(
response.body, infer_missing=True
)
@@ -133,18 +160,25 @@ class GenericTag(Tag, Tag.AssociatedObjects):
return objects_resp.metadata_objects()
- def get_response(self, url: str, error_handler: ErrorHandler) -> Response:
+ def get_response(
+ self,
+ url: str,
+ error_handler: ErrorHandler,
+ params: Optional[dict[str, str]] = None,
+ ) -> 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.
+ params (dict[str, str]): The query parameters to send.
Returns:
Response: The response from the server.
"""
return self._client.get(
url,
+ params=params or {},
error_handler=error_handler,
)
diff --git a/clients/client-python/gravitino/client/generic_view.py
b/clients/client-python/gravitino/client/generic_view.py
index 991a2e4ce0..ab1919a2f9 100644
--- a/clients/client-python/gravitino/client/generic_view.py
+++ b/clients/client-python/gravitino/client/generic_view.py
@@ -95,6 +95,13 @@ class GenericView(View, SupportsTags):
def get_tag(self, name: str) -> Tag:
return self._object_tag_operations.get_tag(name)
+ def assign_tags(
+ self,
+ tags_to_add: list[str | dict[str, str | None]] | None = None,
+ tags_to_remove: list[str | dict[str, str | None]] | None = None,
+ ) -> list[str]:
+ return self._object_tag_operations.assign_tags(tags_to_add,
tags_to_remove)
+
def associate_tags(
self, tags_to_add: list[str], tags_to_remove: list[str]
) -> list[str]:
diff --git a/clients/client-python/gravitino/client/gravitino_client.py
b/clients/client-python/gravitino/client/gravitino_client.py
index 3e936d2eb0..3811400987 100644
--- a/clients/client-python/gravitino/client/gravitino_client.py
+++ b/clients/client-python/gravitino/client/gravitino_client.py
@@ -287,7 +287,7 @@ class GravitinoClient(GravitinoClientBase, SupportsJobs,
TagOperations):
"""
return self.get_metalake().get_tag(tag_name)
- def create_tag(self, tag_name, comment, properties) -> Tag:
+ def create_tag(self, tag_name, comment, properties, allowed_values=None)
-> Tag:
"""
Create a new tag under a metalake.
@@ -299,11 +299,14 @@ class GravitinoClient(GravitinoClientBase, SupportsJobs,
TagOperations):
tag_name (str): The name of the tag.
comment (str): The comment of the tag.
properties (dict[str, str]): The properties of the tag.
+ allowed_values (list[str] | None): The allowed assignment values.
Returns:
Tag: The tag information.
"""
- return self.get_metalake().create_tag(tag_name, comment, properties)
+ return self.get_metalake().create_tag(
+ tag_name, comment, properties, allowed_values
+ )
def alter_tag(self, tag_name, *changes) -> Tag:
"""
diff --git a/clients/client-python/gravitino/client/gravitino_metalake.py
b/clients/client-python/gravitino/client/gravitino_metalake.py
index beb4baf96a..1fcb75cddb 100644
--- a/clients/client-python/gravitino/client/gravitino_metalake.py
+++ b/clients/client-python/gravitino/client/gravitino_metalake.py
@@ -653,7 +653,7 @@ class GravitinoMetalake(
return GenericTag(self.name(), tag_resp.tag(), self.rest_client)
- def create_tag(self, tag_name, comment, properties) -> Tag:
+ def create_tag(self, tag_name, comment, properties, allowed_values=None)
-> Tag:
"""
Create a new tag under a metalake.
@@ -665,6 +665,7 @@ class GravitinoMetalake(
tag_name (str): The name of the tag.
comment (str): The comment of the tag.
properties (dict[str, str]): The properties of the tag.
+ allowed_values (list[str] | None): The allowed assignment values.
Returns:
Tag: The tag information.
@@ -673,6 +674,7 @@ class GravitinoMetalake(
tag_name,
comment,
properties,
+ allowed_values,
)
tag_create_request.validate()
diff --git
a/clients/client-python/gravitino/client/metadata_object_tag_operations.py
b/clients/client-python/gravitino/client/metadata_object_tag_operations.py
index 8f46ed0c6d..d0883bbc05 100644
--- a/clients/client-python/gravitino/client/metadata_object_tag_operations.py
+++ b/clients/client-python/gravitino/client/metadata_object_tag_operations.py
@@ -21,7 +21,11 @@ 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.requests.tag_associate_request import (
+ TagsAssociateRequest,
+ TagValuePairRequest,
+ TagValuesAssociateRequest,
+)
from gravitino.dto.responses.tag_response import (
TagListResponse,
TagNamesListResponse,
@@ -36,11 +40,16 @@ from gravitino.utils.string_utils import StringUtils
class MetadataObjectTagOperations(SupportsTags):
"""
- The implementation of SupportsTags. This helper is composed into supported
metadata
- objects to provide tag operations.
+ 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"
+ TAG_VALUES_JSON_HEADER = {
+ "Content-Type": "application/vnd.gravitino.v2+json",
+ "Accept": "application/vnd.gravitino.v2+json",
+ }
def __init__(
self,
@@ -110,6 +119,32 @@ class MetadataObjectTagOperations(SupportsTags):
self.rest_client,
)
+ def assign_tags(
+ self,
+ tags_to_add: (
+ list[str | dict[str, str | None] | TagValuePairRequest] | None
+ ) = None,
+ tags_to_remove: (
+ list[str | dict[str, str | None] | TagValuePairRequest] | None
+ ) = None,
+ ) -> list[str]:
+ associate_request = TagValuesAssociateRequest(tags_to_add,
tags_to_remove)
+ associate_request.validate()
+
+ response = self.rest_client.post(
+ self.tag_request_path,
+ json=associate_request,
+ headers=self.TAG_VALUES_JSON_HEADER,
+ error_handler=TAG_ERROR_HANDLER,
+ )
+
+ associate_resp = TagNamesListResponse.from_json(
+ response.body, infer_missing=True
+ )
+ associate_resp.validate()
+
+ return associate_resp.tag_names()
+
def associate_tags(
self, tags_to_add: list[str], tags_to_remove: list[str]
) -> list[str]:
diff --git a/clients/client-python/gravitino/client/relational_table.py
b/clients/client-python/gravitino/client/relational_table.py
index b35e868969..17e59ad7f9 100644
--- a/clients/client-python/gravitino/client/relational_table.py
+++ b/clients/client-python/gravitino/client/relational_table.py
@@ -252,6 +252,13 @@ class RelationalTable(
def get_tag(self, name: str) -> Tag:
return self._object_tag_operations.get_tag(name)
+ def assign_tags(
+ self,
+ tags_to_add: list[str | dict[str, str | None]] | None = None,
+ tags_to_remove: list[str | dict[str, str | None]] | None = None,
+ ) -> list[str]:
+ return self._object_tag_operations.assign_tags(tags_to_add,
tags_to_remove)
+
def associate_tags(
self, tags_to_add: list[str], tags_to_remove: list[str]
) -> list[str]:
diff --git a/clients/client-python/gravitino/dto/requests/__init__.py
b/clients/client-python/gravitino/dto/requests/__init__.py
index c8c8c9800e..95341c8b57 100644
--- a/clients/client-python/gravitino/dto/requests/__init__.py
+++ b/clients/client-python/gravitino/dto/requests/__init__.py
@@ -16,11 +16,19 @@
# under the License.
from gravitino.dto.requests.tag_create_request import TagCreateRequest
+from gravitino.dto.requests.tag_associate_request import (
+ TagsAssociateRequest,
+ TagValuePairRequest,
+ TagValuesAssociateRequest,
+)
from gravitino.dto.requests.tag_update_request import TagUpdateRequest
from gravitino.dto.requests.tag_updates_request import TagUpdatesRequest
__all__ = [
"TagCreateRequest",
+ "TagsAssociateRequest",
+ "TagValuePairRequest",
+ "TagValuesAssociateRequest",
"TagUpdatesRequest",
"TagUpdateRequest",
]
diff --git
a/clients/client-python/gravitino/dto/requests/tag_associate_request.py
b/clients/client-python/gravitino/dto/requests/tag_associate_request.py
index 6b78b0c642..a7eca54669 100644
--- a/clients/client-python/gravitino/dto/requests/tag_associate_request.py
+++ b/clients/client-python/gravitino/dto/requests/tag_associate_request.py
@@ -17,6 +17,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
+from typing import Optional
from dataclasses_json import config, dataclass_json
@@ -25,47 +26,164 @@ from gravitino.utils.precondition import Precondition
from gravitino.utils.string_utils import StringUtils
+@dataclass_json
+@dataclass
+class TagValuePairRequest(RESTRequest):
+ """Represents a tag assignment value pair request."""
+
+ _name: str = field(metadata=config(field_name="name"))
+ _value: Optional[str] = field(default=None,
metadata=config(field_name="value"))
+
+ @property
+ def name(self) -> str:
+ """Gets the tag name."""
+ return self._name
+
+ @property
+ def value(self) -> Optional[str]:
+ """Gets the tag assignment value."""
+ return self._value
+
+ def validate(self) -> None:
+ """Validates the request."""
+ Precondition.check_argument(
+ StringUtils.is_not_blank(self._name),
+ "Tag name must not be null or empty",
+ )
+ if self._value is not None:
+ Precondition.check_argument(
+ self._value.strip() != "",
+ "Tag value must not be empty",
+ )
+ Precondition.check_argument(
+ len(self._value) <= 256,
+ "Tag value must not be longer than 256 characters",
+ )
+
+
@dataclass_json
@dataclass
class TagsAssociateRequest(RESTRequest):
- """
- Represents a request to associate tags.
- """
+ """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"))
+ _tags_to_add: Optional[list[str]] = field(
+ default=None, metadata=config(field_name="tagsToAdd")
+ )
+ _tags_to_remove: Optional[list[str]] = field(
+ default=None, metadata=config(field_name="tagsToRemove")
+ )
@property
- def tags_to_add(self) -> list[str]:
- """
- Gets the tags to add.
- """
+ def tags_to_add(self) -> Optional[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.
- """
+ def tags_to_remove(self) -> Optional[list[str]]:
+ """Gets the tags to remove."""
return self._tags_to_remove
def validate(self) -> None:
- """
- Validates the request.
- """
+ """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")
+ self._validate_tag_names(self._tags_to_add, "tagsToAdd")
+ self._validate_tag_names(self._tags_to_remove, "tagsToRemove")
- def _validate_tags(self, tags: list[str] | None, field_name: str) -> None:
- if tags is None:
+ def _validate_tag_names(
+ self, tag_names: Optional[list[str]], field_name: str
+ ) -> None:
+ if tag_names is None:
return
Precondition.check_argument(
- all(StringUtils.is_not_blank(tag) for tag in tags),
+ all(StringUtils.is_not_blank(tag_name) for tag_name in tag_names),
f"{field_name} must not contain null or empty tag names",
)
+
+
+@dataclass_json
+@dataclass
+class TagValuesAssociateRequest(RESTRequest):
+ """Represents a request to associate tag-value pairs."""
+
+ _tags_to_add: Optional[list[TagValuePairRequest]] = field(
+ default=None, metadata=config(field_name="tagsToAdd")
+ )
+ _tags_to_remove: Optional[list[TagValuePairRequest]] = field(
+ default=None, metadata=config(field_name="tagsToRemove")
+ )
+
+ def __post_init__(self) -> None:
+ self._tags_to_add = self._normalize_pairs(self._tags_to_add)
+ self._tags_to_remove = self._normalize_pairs(self._tags_to_remove)
+
+ @property
+ def tags_to_add(self) -> Optional[list[TagValuePairRequest]]:
+ """Gets the tags to add."""
+ return self._tags_to_add
+
+ @property
+ def tags_to_remove(self) -> Optional[list[TagValuePairRequest]]:
+ """Gets the tags to remove."""
+ return self._tags_to_remove
+
+ def validate(self) -> None:
+ """Validates the request."""
+ Precondition.check_argument(
+ bool(self._tags_to_add) or bool(self._tags_to_remove),
+ "tagsToAdd and tagsToRemove cannot both be null or empty",
+ )
+
+ self._validate_pairs(self._tags_to_add, "tagsToAdd")
+ self._validate_pairs(self._tags_to_remove, "tagsToRemove")
+ self._validate_no_intersection()
+
+ def _normalize_pairs(
+ self, pairs: list[str | dict[str, Optional[str]] |
TagValuePairRequest] | None
+ ) -> Optional[list[TagValuePairRequest]]:
+ if pairs is None:
+ return None
+
+ normalized_pairs = []
+ for pair in pairs:
+ if isinstance(pair, TagValuePairRequest):
+ normalized_pairs.append(pair)
+ elif isinstance(pair, str):
+ normalized_pairs.append(TagValuePairRequest(pair))
+ elif isinstance(pair, dict):
+ normalized_pairs.append(
+ TagValuePairRequest(pair.get("name"), pair.get("value"))
+ )
+ else:
+ raise TypeError(f"Unsupported tag value pair type:
{type(pair)}")
+
+ return normalized_pairs
+
+ def _validate_pairs(
+ self, pairs: Optional[list[TagValuePairRequest]], field_name: str
+ ) -> None:
+ if pairs is None:
+ return
+
+ Precondition.check_argument(
+ all(pair is not None for pair in pairs),
+ f"{field_name} must not contain null tag value pairs",
+ )
+ for pair in pairs:
+ pair.validate()
+
+ def _validate_no_intersection(self) -> None:
+ if not self._tags_to_add or not self._tags_to_remove:
+ return
+
+ tags_to_add = {(pair.name, pair.value) for pair in self._tags_to_add}
+ tags_to_remove = {(pair.name, pair.value) for pair in
self._tags_to_remove}
+
+ Precondition.check_argument(
+ not tags_to_add.intersection(tags_to_remove),
+ "tagsToAdd and tagsToRemove must not contain the same tag-value
pair",
+ )
diff --git a/clients/client-python/gravitino/dto/requests/tag_create_request.py
b/clients/client-python/gravitino/dto/requests/tag_create_request.py
index 416d2ac67f..f9d9882a53 100644
--- a/clients/client-python/gravitino/dto/requests/tag_create_request.py
+++ b/clients/client-python/gravitino/dto/requests/tag_create_request.py
@@ -35,6 +35,9 @@ class TagCreateRequest(RESTRequest):
_properties: Optional[dict[str, str]] = field(
default_factory=dict, metadata=config(field_name="properties")
)
+ _allowed_values: Optional[list[str]] = field(
+ default=None, metadata=config(field_name="allowedValues")
+ )
def validate(self) -> None:
"""
@@ -44,3 +47,18 @@ class TagCreateRequest(RESTRequest):
Precondition.check_string_not_empty(
self._name, "name is required and cannot be empty"
)
+
+ if self._allowed_values is None:
+ return
+
+ Precondition.check_argument(
+ all(
+ value is not None and value.strip() != ""
+ for value in self._allowed_values
+ ),
+ "allowedValues must not contain null or empty values",
+ )
+ Precondition.check_argument(
+ all(len(value) <= 256 for value in self._allowed_values),
+ "allowedValues must not contain values longer than 256 characters",
+ )
diff --git a/clients/client-python/gravitino/dto/tag_dto.py
b/clients/client-python/gravitino/dto/tag_dto.py
index 8f4568662a..9a9baae76e 100644
--- a/clients/client-python/gravitino/dto/tag_dto.py
+++ b/clients/client-python/gravitino/dto/tag_dto.py
@@ -33,11 +33,16 @@ class TagDTO(Tag):
_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")
)
+ _allowed_values: Optional[list[str]] = field(
+ default=None, metadata=config(field_name="allowedValues")
+ )
+ _assignment_values: Optional[list[str]] = field(
+ default=None, metadata=config(field_name="assignmentValues")
+ )
def __eq__(self, other: object):
if not isinstance(other, TagDTO):
@@ -46,6 +51,7 @@ class TagDTO(Tag):
self._name == other._name
and self._comment == other._comment
and self._properties == other._properties
+ and self._allowed_values == other._allowed_values
and self._audit == other._audit
)
@@ -55,6 +61,11 @@ class TagDTO(Tag):
self._name,
self._comment,
frozenset(self._properties.items()) if self._properties else
None,
+ (
+ tuple(self._allowed_values)
+ if self._allowed_values is not None
+ else None
+ ),
self._audit,
)
)
@@ -88,6 +99,22 @@ class TagDTO(Tag):
"""
return self._properties
+ def allowed_values(self) -> Optional[list[str]]:
+ """Get the allowed values for this tag.
+
+ Returns:
+ Optional[list[str]]: The allowed values, or None if values are
unrestricted.
+ """
+ return self._allowed_values
+
+ def assignment_values(self) -> Optional[list[str]]:
+ """Get assignment values when this tag is loaded from a metadata
object.
+
+ Returns:
+ Optional[list[str]]: The assignment values, or None if not
assignment-scoped.
+ """
+ return self._assignment_values
+
def audit_info(self) -> AuditDTO:
"""
Get the audit information of the tag.
@@ -119,6 +146,8 @@ class TagDTO(Tag):
self._name = ""
self._comment = ""
self._properties: dict[str, str] = {}
+ self._allowed_values = None
+ self._assignment_values = None
self._audit = None
self._inherited = True
@@ -134,6 +163,16 @@ class TagDTO(Tag):
self._properties = properties
return self
+ def allowed_values(self, allowed_values: Optional[list[str]]) ->
TagDTO.Builder:
+ self._allowed_values = allowed_values
+ return self
+
+ def assignment_values(
+ self, assignment_values: Optional[list[str]]
+ ) -> TagDTO.Builder:
+ self._assignment_values = assignment_values
+ return self
+
def audit_info(self, audit: AuditDTO) -> TagDTO.Builder:
self._audit = audit
return self
@@ -144,9 +183,11 @@ class TagDTO(Tag):
def build(self) -> TagDTO:
return TagDTO(
- self._name,
- self._comment,
- self._properties,
- self._audit,
- self._inherited,
+ _name=self._name,
+ _comment=self._comment,
+ _properties=self._properties,
+ _audit=self._audit,
+ _inherited=self._inherited,
+ _allowed_values=self._allowed_values,
+ _assignment_values=self._assignment_values,
)
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
index 2e0b6f929f..61f4b5e94a 100644
---
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
@@ -22,7 +22,10 @@ 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.requests.tag_associate_request import (
+ TagsAssociateRequest,
+ TagValuesAssociateRequest,
+)
from gravitino.dto.responses.tag_response import (
TagListResponse,
TagNamesListResponse,
@@ -182,6 +185,106 @@ class TestMetadataObjectTagOperations(unittest.TestCase):
error_handler=TAG_ERROR_HANDLER,
)
+ def test_associate_tags_allows_empty_lists(self) -> None:
+ tag_operations = MetadataObjectTagOperations(
+ TestMetadataObjectTagOperations.METALAKE_NAME,
+ MetadataObjects.of(
+ ["catalog", "schema", "table"],
+ MetadataObject.Type.TABLE,
+ ),
+ TestMetadataObjectTagOperations.REST_CLIENT,
+ )
+ json_str = TagNamesListResponse(0, ["tagA"]).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([], [])
+
+ self.assertEqual(["tagA"], tags)
+ param = TagsAssociateRequest([], [])
+ mock_post.assert_called_once_with(
+
"api/metalakes/demo_metalake/objects/table/catalog.schema.table/tags",
+ json=param,
+ error_handler=TAG_ERROR_HANDLER,
+ )
+
+ def test_assign_tag_values(self) -> None:
+ tag_operations = MetadataObjectTagOperations(
+ TestMetadataObjectTagOperations.METALAKE_NAME,
+ MetadataObjects.of(
+ ["catalog", "schema", "table"],
+ MetadataObject.Type.TABLE,
+ ),
+ TestMetadataObjectTagOperations.REST_CLIENT,
+ )
+ json_str = TagNamesListResponse(0, ["data_domain"]).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.assign_tags(
+ [
+ {"name": "data_domain", "value": "finance"},
+ {"name": "data_domain", "value": "risk"},
+ {"name": "pii", "value": None},
+ ],
+ [{"name": "deprecated", "value": None}],
+ )
+
+ self.assertEqual(["data_domain"], tags)
+ param = TagValuesAssociateRequest(
+ [
+ {"name": "data_domain", "value": "finance"},
+ {"name": "data_domain", "value": "risk"},
+ {"name": "pii", "value": None},
+ ],
+ [{"name": "deprecated", "value": None}],
+ )
+
+ mock_post.assert_called_once_with(
+
"api/metalakes/demo_metalake/objects/table/catalog.schema.table/tags",
+ json=param,
+ headers=MetadataObjectTagOperations.TAG_VALUES_JSON_HEADER,
+ error_handler=TAG_ERROR_HANDLER,
+ )
+
+ def test_assign_tag_values_with_default_remove(self) -> None:
+ tag_operations = MetadataObjectTagOperations(
+ TestMetadataObjectTagOperations.METALAKE_NAME,
+ MetadataObjects.of(
+ ["catalog", "schema", "table"],
+ MetadataObject.Type.TABLE,
+ ),
+ TestMetadataObjectTagOperations.REST_CLIENT,
+ )
+ json_str = TagNamesListResponse(0, ["data_domain"]).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.assign_tags(
+ [{"name": "data_domain", "value": "finance"}]
+ )
+
+ self.assertEqual(["data_domain"], tags)
+ param = TagValuesAssociateRequest(
+ [{"name": "data_domain", "value": "finance"}], None
+ )
+
+ mock_post.assert_called_once_with(
+
"api/metalakes/demo_metalake/objects/table/catalog.schema.table/tags",
+ json=param,
+ headers=MetadataObjectTagOperations.TAG_VALUES_JSON_HEADER,
+ 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())
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
index 40724cceea..4d9396b61a 100644
---
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
@@ -42,3 +42,14 @@ class TestTagCreateRequest(unittest.TestCase):
self.assertEqual("tag_test", deser_dict["name"])
self.assertEqual("tag comment", deser_dict["comment"])
self.assertIsNotNone(deser_dict.get("properties"))
+
+ # test with allowed values
+ tag_create_request = TagCreateRequest(
+ "tag_test",
+ "tag comment",
+ {"key1": "value1"},
+ ["finance", "risk"],
+ )
+ ser_json = _json.dumps(tag_create_request.to_dict())
+ deser_dict = _json.loads(ser_json)
+ self.assertEqual(["finance", "risk"], deser_dict.get("allowedValues"))
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
index 2a208aa95c..47c22fa20f 100644
---
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
@@ -19,19 +19,21 @@ from __future__ import annotations
import json as _json
import unittest
-from gravitino.dto.requests.tag_associate_request import TagsAssociateRequest
+from gravitino.dto.requests.tag_associate_request import (
+ TagsAssociateRequest,
+ TagValuePairRequest,
+ TagValuesAssociateRequest,
+)
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"]
- )
+ def test_create_tag_names_request(self) -> None:
+ request = TagsAssociateRequest(["tag_to_add"], ["tag_to_remove"])
json_str = _json.dumps(
{
- "tagsToAdd": ["tag_to_add_1", "tag_to_add_2"],
- "tagsToRemove": ["tag_to_remove_1", "tag_to_remove_2"],
+ "tagsToAdd": ["tag_to_add"],
+ "tagsToRemove": ["tag_to_remove"],
}
)
@@ -39,23 +41,93 @@ class TestTagsAssociateRequest(unittest.TestCase):
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_add"], deserialized_request.tags_to_add)
+ self.assertEqual(["tag_to_remove"],
deserialized_request.tags_to_remove)
+
+ def test_tag_names_request_validate(self) -> None:
+ TagsAssociateRequest([], []).validate()
+
+ invalid_request1 = TagsAssociateRequest(None, None)
+ invalid_request2 = TagsAssociateRequest(["tag_to_add", " "], None)
+
+ with self.assertRaises(IllegalArgumentException):
+ invalid_request1.validate()
+
+ with self.assertRaises(IllegalArgumentException):
+ invalid_request2.validate()
+
+
+class TestTagValuesAssociateRequest(unittest.TestCase):
+ def test_create_request(self) -> None:
+ request = TagValuesAssociateRequest(
+ [
+ {"name": "tag_to_add_1", "value": "value1"},
+ TagValuePairRequest("tag_to_add_2"),
+ ],
+ ["tag_to_remove_1", {"name": "tag_to_remove_2", "value":
"value2"}],
)
- self.assertEqual(
- ["tag_to_remove_1", "tag_to_remove_2"],
deserialized_request.tags_to_remove
+ json_str = _json.dumps(
+ {
+ "tagsToAdd": [
+ {"name": "tag_to_add_1", "value": "value1"},
+ {"name": "tag_to_add_2", "value": None},
+ ],
+ "tagsToRemove": [
+ {"name": "tag_to_remove_1", "value": None},
+ {"name": "tag_to_remove_2", "value": "value2"},
+ ],
+ }
)
+ self.assertEqual(json_str, request.to_json())
+ deserialized_request = TagValuesAssociateRequest.from_json(json_str)
+
+ self.assertTrue(isinstance(deserialized_request,
TagValuesAssociateRequest))
+ self.assertEqual("tag_to_add_1",
deserialized_request.tags_to_add[0].name)
+ self.assertEqual("value1", deserialized_request.tags_to_add[0].value)
+ self.assertEqual("tag_to_add_2",
deserialized_request.tags_to_add[1].name)
+ self.assertIsNone(deserialized_request.tags_to_add[1].value)
+ self.assertEqual("tag_to_remove_1",
deserialized_request.tags_to_remove[0].name)
+ self.assertIsNone(deserialized_request.tags_to_remove[0].value)
+ self.assertEqual("tag_to_remove_2",
deserialized_request.tags_to_remove[1].name)
+ self.assertEqual("value2",
deserialized_request.tags_to_remove[1].value)
+
def test_associate_request_validate(self) -> None:
- invalid_request1 = TagsAssociateRequest(
+ TagValuesAssociateRequest(
+ [{"name": "data_domain", "value": "finance"}],
+ [{"name": "data_domain", "value": "risk"}],
+ ).validate()
+
+ invalid_request1 = TagValuesAssociateRequest(
None, None
) # pyright: ignore[reportArgumentType]
- invalid_request2 = TagsAssociateRequest(
+ invalid_request2 = TagValuesAssociateRequest(
["tag_to_add_1", " "], ["tag_to_remove_1", "tag_to_remove_2"]
)
+ invalid_request3 = TagValuesAssociateRequest(
+ [{"name": "tag_to_add_1", "value": " "}], None
+ )
+ invalid_request4 = TagValuesAssociateRequest([], [])
+ invalid_request5 = TagValuesAssociateRequest(
+ [{"name": "data_domain", "value": "finance"}],
+ [{"name": "data_domain", "value": "finance"}],
+ )
+ invalid_request6 = TagValuesAssociateRequest(["pii"], ["pii"])
with self.assertRaises(IllegalArgumentException):
invalid_request1.validate()
with self.assertRaises(IllegalArgumentException):
invalid_request2.validate()
+
+ with self.assertRaises(IllegalArgumentException):
+ invalid_request3.validate()
+
+ with self.assertRaises(IllegalArgumentException):
+ invalid_request4.validate()
+
+ with self.assertRaises(IllegalArgumentException):
+ invalid_request5.validate()
+
+ with self.assertRaises(IllegalArgumentException):
+ invalid_request6.validate()
diff --git a/clients/client-python/tests/unittests/dto/test_tag_dto.py
b/clients/client-python/tests/unittests/dto/test_tag_dto.py
index f173c2606e..47f0dac034 100644
--- a/clients/client-python/tests/unittests/dto/test_tag_dto.py
+++ b/clients/client-python/tests/unittests/dto/test_tag_dto.py
@@ -18,15 +18,12 @@ from __future__ import annotations
import json as _json
import unittest
-from datetime import datetime, timezone
from gravitino.dto.audit_dto import AuditDTO
from gravitino.dto.tag_dto import TagDTO
class TestTagDTO(unittest.TestCase):
- AUDIT_TIME = datetime(2022, 1, 1, tzinfo=timezone.utc)
-
def test_create_tag_dto(self):
builder = TagDTO.builder()
tag_dto = (
@@ -38,7 +35,9 @@ class TestTagDTO(unittest.TestCase):
"key2": "value2",
}
)
- .audit_info(AuditDTO("test_user", self.AUDIT_TIME))
+ .allowed_values(["finance", "risk"])
+ .assignment_values(["finance"])
+ .audit_info(AuditDTO("test_user", "2022-01-01T00:00:00Z"))
.inherited(True)
.build()
)
@@ -48,9 +47,20 @@ class TestTagDTO(unittest.TestCase):
self.assertEqual(deser_dict["comment"], "test_comment")
self.assertEqual(deser_dict["properties"], {"key1": "value1", "key2":
"value2"})
self.assertTrue(deser_dict["inherited"])
+ self.assertEqual(["finance", "risk"], deser_dict["allowedValues"])
+ self.assertEqual(["finance"], deser_dict["assignmentValues"])
self.assertEqual(deser_dict["audit"]["creator"], "test_user")
self.assertEqual(deser_dict["audit"]["createTime"],
"2022-01-01T00:00:00Z")
+ def
test_positional_constructor_keeps_audit_and_inherited_compatibility(self):
+ audit = AuditDTO("test_user", "2022-01-01T00:00:00Z")
+ tag_dto = TagDTO("test_tag", "test_comment", {}, audit, False)
+
+ self.assertEqual(audit, tag_dto.audit_info())
+ self.assertFalse(tag_dto.inherited())
+ self.assertIsNone(tag_dto.allowed_values())
+ self.assertIsNone(tag_dto.assignment_values())
+
def test_equality_and_hash(self):
builder = TagDTO.builder()
tag_dto1 = (
@@ -62,7 +72,7 @@ class TestTagDTO(unittest.TestCase):
"key2": "value2",
}
)
- .audit_info(AuditDTO("test_user", self.AUDIT_TIME))
+ .audit_info(AuditDTO("test_user", "2022-01-01T00:00:00Z"))
.inherited(True)
.build()
)
@@ -75,7 +85,7 @@ class TestTagDTO(unittest.TestCase):
"key2": "value2",
}
)
- .audit_info(AuditDTO("test_user", self.AUDIT_TIME))
+ .audit_info(AuditDTO("test_user", "2022-01-01T00:00:00Z"))
.inherited(True)
.build()
)
@@ -88,7 +98,7 @@ class TestTagDTO(unittest.TestCase):
"key2": "value3",
}
)
- .audit_info(AuditDTO("test_user", self.AUDIT_TIME))
+ .audit_info(AuditDTO("test_user", "2022-01-01T00:00:00Z"))
.inherited(False)
.build()
)
@@ -101,3 +111,59 @@ class TestTagDTO(unittest.TestCase):
self.assertNotEqual(tag_dto2, tag_dto3)
self.assertNotEqual(hash(tag_dto2), hash(tag_dto3))
+
+ def test_assignment_values_do_not_affect_equality(self):
+ tag_dto1 = (
+ TagDTO.builder()
+ .name("test_tag")
+ .comment("test_comment")
+ .properties({"key1": "value1"})
+ .audit_info(AuditDTO("test_user", "2022-01-01T00:00:00Z"))
+ .build()
+ )
+ tag_dto2 = (
+ TagDTO.builder()
+ .name("test_tag")
+ .comment("test_comment")
+ .properties({"key1": "value1"})
+ .assignment_values([])
+ .audit_info(AuditDTO("test_user", "2022-01-01T00:00:00Z"))
+ .build()
+ )
+ tag_dto3 = (
+ TagDTO.builder()
+ .name("test_tag")
+ .comment("test_comment")
+ .properties({"key1": "value1"})
+ .assignment_values(["finance"])
+ .audit_info(AuditDTO("test_user", "2022-01-01T00:00:00Z"))
+ .build()
+ )
+
+ self.assertEqual(tag_dto1, tag_dto2)
+ self.assertEqual(tag_dto1, tag_dto3)
+ self.assertEqual(hash(tag_dto1), hash(tag_dto2))
+ self.assertEqual(hash(tag_dto1), hash(tag_dto3))
+
+ def test_allowed_values_affect_equality(self):
+ tag_dto1 = (
+ TagDTO.builder()
+ .name("test_tag")
+ .comment("test_comment")
+ .properties({"key1": "value1"})
+ .allowed_values(["finance"])
+ .audit_info(AuditDTO("test_user", "2022-01-01T00:00:00Z"))
+ .build()
+ )
+ tag_dto2 = (
+ TagDTO.builder()
+ .name("test_tag")
+ .comment("test_comment")
+ .properties({"key1": "value1"})
+ .allowed_values(["risk"])
+ .audit_info(AuditDTO("test_user", "2022-01-01T00:00:00Z"))
+ .build()
+ )
+
+ self.assertNotEqual(tag_dto1, tag_dto2)
+ self.assertNotEqual(hash(tag_dto1), hash(tag_dto2))
diff --git a/clients/client-python/tests/unittests/mock_base.py
b/clients/client-python/tests/unittests/mock_base.py
index 3f1cd15494..684dc77992 100644
--- a/clients/client-python/tests/unittests/mock_base.py
+++ b/clients/client-python/tests/unittests/mock_base.py
@@ -106,6 +106,8 @@ def build_tag_dto(
name: str = "tagA",
comment: str = "commentA",
properties: tp.Optional[dict[str, str]] = None,
+ allowed_values: tp.Optional[list[str]] = None,
+ assignment_values: tp.Optional[list[str]] = None,
) -> TagDTO:
if properties is None:
properties = {
@@ -118,6 +120,8 @@ def build_tag_dto(
.name(name)
.comment(comment)
.properties(properties)
+ .allowed_values(allowed_values)
+ .assignment_values(assignment_values)
.audit_info(build_audit_info())
.inherited(False)
.build()
@@ -282,10 +286,13 @@ class MockTagRepo:
tag_name: str,
comment: str = "",
properties=None,
+ allowed_values=None,
) -> TagDTO:
if tag_name in self.tag_store:
raise ValueError(f"Tag {tag_name} already exists")
- self.tag_store[tag_name] = build_tag_dto(tag_name, comment, properties)
+ self.tag_store[tag_name] = build_tag_dto(
+ tag_name, comment, properties, allowed_values
+ )
return self.tag_store[tag_name]
def mock_alter_tag(self, tag_name: str, *changes) -> TagDTO:
diff --git a/clients/client-python/tests/unittests/test_generic_function.py
b/clients/client-python/tests/unittests/test_generic_function.py
index 85ad00af1e..b497dcab53 100644
--- a/clients/client-python/tests/unittests/test_generic_function.py
+++ b/clients/client-python/tests/unittests/test_generic_function.py
@@ -70,7 +70,13 @@ class TestGenericFunction(unittest.TestCase):
generic_function = self._generic_function()
self.assertTrue(issubclass(GenericFunction, SupportsTags))
- expected_methods = ["list_tags", "list_tags_info", "get_tag",
"associate_tags"]
+ expected_methods = [
+ "list_tags",
+ "list_tags_info",
+ "get_tag",
+ "assign_tags",
+ "associate_tags",
+ ]
self.assertTrue(
all(
callable(getattr(generic_function, method, None))
diff --git a/clients/client-python/tests/unittests/test_generic_tag.py
b/clients/client-python/tests/unittests/test_generic_tag.py
index 2fee407b43..eb7b1726a5 100644
--- a/clients/client-python/tests/unittests/test_generic_tag.py
+++ b/clients/client-python/tests/unittests/test_generic_tag.py
@@ -26,7 +26,11 @@ 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.exceptions.base import (
+ IllegalArgumentException,
+ InternalError,
+ NoSuchMetalakeException,
+)
from gravitino.utils import HTTPClient
from gravitino.utils.http_client import Response
@@ -39,6 +43,8 @@ class TestGenericTag(unittest.TestCase):
.name("tag1")
.comment("comment1")
.properties({"key1": "value1"})
+ .allowed_values(["finance", "risk"])
+ .assignment_values(["finance"])
.inherited(True)
.audit_info(AuditDTO(_creator="test",
_create_time="2022-01-01T00:00:00Z"))
.build()
@@ -54,6 +60,8 @@ class TestGenericTag(unittest.TestCase):
self.assertEqual("tag1", generic_tag.name())
self.assertEqual("comment1", generic_tag.comment())
self.assertEqual({"key1": "value1"}, generic_tag.properties())
+ self.assertEqual(["finance", "risk"], generic_tag.allowed_values())
+ self.assertEqual(["finance"], generic_tag.assignment_values())
self.assertEqual(True, generic_tag.inherited())
self.assertEqual(
AuditDTO(_creator="test", _create_time="2022-01-01T00:00:00Z"),
@@ -158,6 +166,56 @@ class TestGenericTag(unittest.TestCase):
)
generic_tag.associated_objects().objects()
+ def
test_generic_tag_associated_objects_supports_legacy_get_response_hook(self):
+ response_body = {
+ "code": 0,
+ "metadataObjects": [
+ {
+ "fullName": "catalog1.schema1.table1",
+ "type": "table",
+ },
+ ],
+ }
+ generic_tag = TestGenericTagEntityWithLegacyGetResponse(
+ self.METALAKE, self.TAG_DTO, self._rest_client, response_body
+ )
+
+ objects = generic_tag.associated_objects().objects()
+
+ self.assertEqual(1, len(objects))
+ self.assertEqual("catalog1.schema1.table1", objects[0].full_name())
+
+ def test_generic_tag_associated_objects_with_value_filter(self):
+ response_body = {
+ "code": 0,
+ "metadataObjects": [
+ {
+ "fullName": "catalog1.schema1.table1",
+ "type": "table",
+ },
+ ],
+ }
+ generic_tag = TestGenericTagEntity(
+ self.METALAKE, self.TAG_DTO, self._rest_client, response_body
+ )
+
+ objects = generic_tag.associated_objects().objects(value="finance")
+
+ self.assertEqual(1, len(objects))
+ self.assertEqual("catalog1.schema1.table1", objects[0].full_name())
+ self.assertEqual({"value": "finance"}, generic_tag.params)
+
+ def test_generic_tag_associated_objects_with_invalid_value_filter(self):
+ generic_tag = TestGenericTagEntity(
+ self.METALAKE, self.TAG_DTO, self._rest_client, {"code": 0}
+ )
+
+ with self.assertRaises(IllegalArgumentException):
+ generic_tag.associated_objects().objects(value=" ")
+
+ with self.assertRaises(IllegalArgumentException):
+ generic_tag.associated_objects().objects(value="v" * 257)
+
def test_hash_and_equal(self) -> None:
tag_dto1 = (
TagDTO.Builder()
@@ -215,8 +273,10 @@ class TestGenericTagEntity(GenericTag):
)
self.__dump_object = dump_object
self.__throw_error = throw_error
+ self.params = None
- def get_response(self, url, _=None) -> Response[MagicMock]:
+ def get_response(self, url, _=None, params=None) -> Response[MagicMock]:
+ self.params = params
if self.__throw_error is not None:
raise self.__throw_error(f"Raise {self.__throw_error.__name__}
Error")
@@ -230,3 +290,10 @@ class TestGenericTagEntity(GenericTag):
mock_response.info.return_value = {"Content-Type": "application/json"}
return Response(mock_response)
+
+
+class TestGenericTagEntityWithLegacyGetResponse(TestGenericTagEntity):
+ def get_response( # pylint: disable=arguments-differ
+ self, url, _=None
+ ) -> Response[MagicMock]:
+ return super().get_response(url, _)
diff --git a/clients/client-python/tests/unittests/test_generic_view.py
b/clients/client-python/tests/unittests/test_generic_view.py
index 6eac593f5e..e3ff0ff6b5 100644
--- a/clients/client-python/tests/unittests/test_generic_view.py
+++ b/clients/client-python/tests/unittests/test_generic_view.py
@@ -76,7 +76,13 @@ class TestGenericView(unittest.TestCase):
generic_view = self._generic_view()
self.assertTrue(issubclass(GenericView, SupportsTags))
- expected_methods = ["list_tags", "list_tags_info", "get_tag",
"associate_tags"]
+ expected_methods = [
+ "list_tags",
+ "list_tags_info",
+ "get_tag",
+ "assign_tags",
+ "associate_tags",
+ ]
self.assertTrue(
all(
callable(getattr(generic_view, method, None))
diff --git a/clients/client-python/tests/unittests/test_tag_api.py
b/clients/client-python/tests/unittests/test_tag_api.py
index 0163ba7c3a..5b24f3452b 100644
--- a/clients/client-python/tests/unittests/test_tag_api.py
+++ b/clients/client-python/tests/unittests/test_tag_api.py
@@ -25,7 +25,10 @@ from gravitino.api.tag import Tag
from gravitino.api.tag.supports_tags import SupportsTags
from gravitino.api.tag.tag_change import TagChange
from gravitino.client.generic_tag import GenericTag
-from gravitino.exceptions.base import IllegalArgumentException
+from gravitino.exceptions.base import (
+ IllegalArgumentException,
+ UnsupportedOperationException,
+)
from gravitino.dto.responses.drop_response import DropResponse
from gravitino.name_identifier import NameIdentifier
from gravitino.dto.responses.tag_response import (
@@ -40,6 +43,46 @@ from tests.unittests import mock_base
class TestTagAPI(unittest.TestCase):
_metalake_name: str = "metalake_demo"
+ def test_new_tag_api_compatibility_defaults(self, *mock_method) -> None:
+ class LegacyTag(Tag):
+ def name(self) -> str:
+ return "tagA"
+
+ def comment(self) -> str:
+ return "comment"
+
+ def properties(self) -> dict[str, str]:
+ return {}
+
+ def audit_info(self):
+ return None
+
+ def inherited(self):
+ return None
+
+ class LegacySupportsTags(SupportsTags):
+ def list_tags(self) -> list[str]:
+ return []
+
+ def list_tags_info(self) -> list[Tag]:
+ return []
+
+ def get_tag(self, name: str) -> Tag:
+ return LegacyTag()
+
+ def associate_tags(
+ self, tags_to_add: list[str], tags_to_remove: list[str]
+ ) -> list[str]:
+ return []
+
+ tag = LegacyTag()
+ self.assertIsNone(tag.allowed_values())
+ self.assertIsNone(tag.assignment_values())
+
+ supports_tags = LegacySupportsTags()
+ with self.assertRaises(UnsupportedOperationException):
+ supports_tags.assign_tags([], [])
+
def test_client_get_tag(self, *mock_method) -> None:
with mock_base.mock_tag_methods():
client = GravitinoClient(
@@ -110,6 +153,25 @@ class TestTagAPI(unittest.TestCase):
self.assertTrue("tagA" not in retrieved_tags)
self.assertTrue("tagB" in retrieved_tags)
+ def test_client_create_tag_with_allowed_values(self, *mock_method) -> None:
+ with mock_base.mock_tag_methods():
+ client = GravitinoClient(
+ uri="http://localhost:8090",
+ metalake_name=self._metalake_name,
+ check_version=False,
+ )
+
+ tag = client.create_tag(
+ "data_domain",
+ "Business data domain",
+ None,
+ ["finance", "risk"],
+ )
+
+ self.assertEqual("data_domain", tag.name())
+ self.assertEqual(["finance", "risk"], tag.allowed_values())
+ self.assertIsNone(tag.assignment_values())
+
def test_client_create_tag(self, *mock_method) -> None:
with mock_base.mock_tag_methods():
client = GravitinoClient(