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(

Reply via email to