This is an automated email from the ASF dual-hosted git repository.

jerryshao pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/main by this push:
     new 8419409569 [#12322] improvement(client-python): Deserialize audit 
timestamps as datetime (#12323)
8419409569 is described below

commit 8419409569081ab96a5f31fffccbab256ce4fd06
Author: Zhiguo Wu <[email protected]>
AuthorDate: Wed Aug 12 13:53:44 2026 +0800

    [#12322] improvement(client-python): Deserialize audit timestamps as 
datetime (#12323)
    
    ### What changes were proposed in this pull request?
    
    - Change the audit timestamp fields in `AuditDTO` to use `datetime`.
    - Add ISO-8601 serialization and deserialization for `createTime` and
      `lastModifiedTime`.
    - Add dedicated `AuditDTO` tests and update affected existing tests.
    
    ### Why are the changes needed?
    
    `AuditDTO` currently keeps audit timestamps as strings because datetime
    deserialization was not implemented. This change completes that support
    and
    aligns `AuditDTO` with the public Python `Audit` API.
    
    Fix: #12322
    
    ### Does this PR introduce _any_ user-facing change?
    
    Yes. `Audit.create_time()` and `Audit.last_modified_time()` now return
    `datetime` values instead of strings. Their JSON representation remains
    ISO-8601.
    
    ### How was this patch tested?
    
    The Python client unit tests, formatting checks, and static analysis
    checks
    were run successfully.
---
 clients/client-python/gravitino/api/audit.py       |  23 +++--
 clients/client-python/gravitino/dto/audit_dto.py   |  75 +++++++++++---
 .../tests/unittests/dto/rel/test_table_dto.py      |   3 +-
 .../unittests/dto/responses/test_responses.py      |   7 +-
 .../tests/unittests/dto/test_audit_dto.py          | 115 +++++++++++++++++++++
 .../tests/unittests/dto/test_tag_dto.py            |  13 ++-
 .../tests/unittests/test_relational_table.py       |   3 +-
 7 files changed, 204 insertions(+), 35 deletions(-)

diff --git a/clients/client-python/gravitino/api/audit.py 
b/clients/client-python/gravitino/api/audit.py
index 395c3d6488..48530f877b 100644
--- a/clients/client-python/gravitino/api/audit.py
+++ b/clients/client-python/gravitino/api/audit.py
@@ -17,41 +17,44 @@
 
 from abc import ABC, abstractmethod
 from datetime import datetime
+from typing import Optional
 
 
 class Audit(ABC):
     """Represents the audit information of an entity."""
 
     @abstractmethod
-    def creator(self) -> str:
+    def creator(self) -> Optional[str]:
         """The creator of the entity.
 
         Returns:
-             the creator of the entity.
+             The creator of the entity, or ``None`` if unavailable.
         """
         pass
 
     @abstractmethod
-    def create_time(self) -> datetime:
+    def create_time(self) -> Optional[datetime]:
         """The creation time of the entity.
 
         Returns:
-             The creation time of the entity.
+             The creation time of the entity, or ``None`` if unavailable.
         """
         pass
 
     @abstractmethod
-    def last_modifier(self) -> str:
-        """
+    def last_modifier(self) -> Optional[str]:
+        """The last modifier of the entity.
+
         Returns:
-             The last modifier of the entity.
+             The last modifier of the entity, or ``None`` if unavailable.
         """
         pass
 
     @abstractmethod
-    def last_modified_time(self) -> datetime:
-        """
+    def last_modified_time(self) -> Optional[datetime]:
+        """The last modified time of the entity.
+
         Returns:
-             The last modified time of the entity.
+             The last modified time of the entity, or ``None`` if unavailable.
         """
         pass
diff --git a/clients/client-python/gravitino/dto/audit_dto.py 
b/clients/client-python/gravitino/dto/audit_dto.py
index b175bda344..9535ca5263 100644
--- a/clients/client-python/gravitino/dto/audit_dto.py
+++ b/clients/client-python/gravitino/dto/audit_dto.py
@@ -15,13 +15,40 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import re
 from dataclasses import dataclass, field
+from datetime import datetime, timezone
 from typing import Optional
 
 from dataclasses_json import DataClassJsonMixin, config
 
 from gravitino.api.audit import Audit
 
+_FRACTIONAL_SECONDS_PATTERN = re.compile(r"(\.\d{6})\d+")
+
+
+def _deserialize_datetime(value: Optional[datetime | str]) -> 
Optional[datetime]:
+    if value is None or isinstance(value, datetime):
+        return value
+    if not isinstance(value, str):
+        raise TypeError(f"Audit time must be an ISO-8601 string, got 
{type(value)}")
+
+    normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
+    # Match Python 3.11+ by truncating nanoseconds to microsecond precision.
+    normalized = _FRACTIONAL_SECONDS_PATTERN.sub(r"\1", normalized, count=1)
+    parsed = datetime.fromisoformat(normalized)
+    return parsed.astimezone(timezone.utc) if parsed.tzinfo is not None else 
parsed
+
+
+def _serialize_datetime(value: Optional[datetime]) -> Optional[str]:
+    if value is None:
+        return None
+    if not isinstance(value, datetime):
+        raise TypeError(f"Audit time must be a datetime, got {type(value)}")
+
+    normalized = value.astimezone(timezone.utc) if value.tzinfo is not None 
else value
+    return normalized.isoformat().replace("+00:00", "Z")
+
 
 @dataclass
 class AuditDTO(Audit, DataClassJsonMixin):
@@ -30,9 +57,14 @@ class AuditDTO(Audit, DataClassJsonMixin):
     _creator: Optional[str] = field(default=None, 
metadata=config(field_name="creator"))
     """The creator of the audit."""
 
-    _create_time: Optional[str] = field(
-        default=None, metadata=config(field_name="createTime")
-    )  # TODO: Can't deserialized datetime from JSON
+    _create_time: Optional[datetime] = field(
+        default=None,
+        metadata=config(
+            field_name="createTime",
+            encoder=_serialize_datetime,
+            decoder=_deserialize_datetime,
+        ),
+    )
     """The create time of the audit."""
 
     _last_modifier: Optional[str] = field(
@@ -40,11 +72,20 @@ class AuditDTO(Audit, DataClassJsonMixin):
     )
     """The last modifier of the audit."""
 
-    _last_modified_time: Optional[str] = field(
-        default=None, metadata=config(field_name="lastModifiedTime")
-    )  # TODO: Can't deserialized datetime from JSON
+    _last_modified_time: Optional[datetime] = field(
+        default=None,
+        metadata=config(
+            field_name="lastModifiedTime",
+            encoder=_serialize_datetime,
+            decoder=_deserialize_datetime,
+        ),
+    )
     """The last modified time of the audit."""
 
+    def __post_init__(self) -> None:
+        self._create_time = _deserialize_datetime(self._create_time)
+        self._last_modified_time = 
_deserialize_datetime(self._last_modified_time)
+
     def __hash__(self):
         return hash(
             (
@@ -65,32 +106,34 @@ class AuditDTO(Audit, DataClassJsonMixin):
             and self.last_modified_time() == other.last_modified_time()
         )
 
-    def creator(self) -> str:
+    def creator(self) -> Optional[str]:
         """The creator of the entity.
 
         Returns:
-             the creator of the entity.
+             The creator of the entity, or ``None`` if unavailable.
         """
         return self._creator
 
-    def create_time(self) -> str:
+    def create_time(self) -> Optional[datetime]:
         """The creation time of the entity.
 
         Returns:
-             The creation time of the entity.
+             The creation time of the entity, or ``None`` if unavailable.
         """
         return self._create_time
 
-    def last_modifier(self) -> str:
-        """
+    def last_modifier(self) -> Optional[str]:
+        """The last modifier of the entity.
+
         Returns:
-             The last modifier of the entity.
+             The last modifier of the entity, or ``None`` if unavailable.
         """
         return self._last_modifier
 
-    def last_modified_time(self) -> str:
-        """
+    def last_modified_time(self) -> Optional[datetime]:
+        """The last modified time of the entity.
+
         Returns:
-             The last modified time of the entity.
+             The last modified time of the entity, or ``None`` if unavailable.
         """
         return self._last_modified_time
diff --git a/clients/client-python/tests/unittests/dto/rel/test_table_dto.py 
b/clients/client-python/tests/unittests/dto/rel/test_table_dto.py
index 293d3935a8..bd78ee1cf5 100644
--- a/clients/client-python/tests/unittests/dto/rel/test_table_dto.py
+++ b/clients/client-python/tests/unittests/dto/rel/test_table_dto.py
@@ -17,6 +17,7 @@
 
 import json
 import unittest
+from datetime import datetime
 
 from gravitino.api.rel.expressions.distributions.strategy import Strategy
 from gravitino.api.rel.expressions.sorts.null_ordering import NullOrdering
@@ -93,7 +94,7 @@ class TestTableDTO(unittest.TestCase):
         dto = TableDTO.from_json(json_string)
         self.assertEqual(dto.name(), "example_table")
         self.assertEqual(dto.audit_info().creator(), "Apache Gravitino")
-        self.assertEqual(dto.audit_info().create_time(), "2025-10-10T00:00:00")
+        self.assertEqual(dto.audit_info().create_time(), datetime(2025, 10, 
10))
         self.assertEqual(len(dto.columns()), 1)
         self.assertIsInstance(dto.columns()[0], ColumnDTO)
 
diff --git 
a/clients/client-python/tests/unittests/dto/responses/test_responses.py 
b/clients/client-python/tests/unittests/dto/responses/test_responses.py
index ee4e44cc94..ecfe946eef 100644
--- a/clients/client-python/tests/unittests/dto/responses/test_responses.py
+++ b/clients/client-python/tests/unittests/dto/responses/test_responses.py
@@ -19,6 +19,7 @@ from __future__ import annotations
 
 import json as _json
 import unittest
+from datetime import datetime, timezone
 
 from gravitino.dto.rel.partitions.json_serdes.partition_dto_serdes import (
     PartitionDTOSerdes,
@@ -128,7 +129,8 @@ class TestResponses(unittest.TestCase):
         self.assertEqual({"key1": "value1"}, model_resp.model().properties())
         self.assertEqual("anonymous", 
model_resp.model().audit_info().creator())
         self.assertEqual(
-            "2024-04-05T10:10:35.218Z", 
model_resp.model().audit_info().create_time()
+            datetime(2024, 4, 5, 10, 10, 35, 218000, tzinfo=timezone.utc),
+            model_resp.model().audit_info().create_time(),
         )
 
         json_data_missing = {
@@ -206,7 +208,8 @@ class TestResponses(unittest.TestCase):
         self.assertEqual({"key1": "value1"}, resp.model_version().properties())
         self.assertEqual("anonymous", 
resp.model_version().audit_info().creator())
         self.assertEqual(
-            "2024-04-05T10:10:35.218Z", 
resp.model_version().audit_info().create_time()
+            datetime(2024, 4, 5, 10, 10, 35, 218000, tzinfo=timezone.utc),
+            resp.model_version().audit_info().create_time(),
         )
 
         json_data = {
diff --git a/clients/client-python/tests/unittests/dto/test_audit_dto.py 
b/clients/client-python/tests/unittests/dto/test_audit_dto.py
new file mode 100644
index 0000000000..f499dec907
--- /dev/null
+++ b/clients/client-python/tests/unittests/dto/test_audit_dto.py
@@ -0,0 +1,115 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import unittest
+from datetime import datetime, timedelta, timezone
+
+from gravitino.dto.audit_dto import AuditDTO
+
+
+class TestAuditDTO(unittest.TestCase):
+    def test_deserialize_datetime(self):
+        audit = AuditDTO.from_json("""{
+              "creator": "alice",
+              "createTime": "2024-04-05T10:10:35.218Z",
+              "lastModifier": "bob",
+              "lastModifiedTime": "2024-04-05T18:10:35.218+08:00"
+            }""")
+
+        expected = datetime(2024, 4, 5, 10, 10, 35, 218000, 
tzinfo=timezone.utc)
+        self.assertEqual(expected, audit.create_time())
+        self.assertEqual(expected, audit.last_modified_time())
+
+    def test_deserialize_nanosecond_datetime(self):
+        audit = AuditDTO.from_json(
+            '{"creator":"alice","createTime":"2026-08-02T16:15:57.286603461Z"}'
+        )
+
+        self.assertEqual(
+            datetime(2026, 8, 2, 16, 15, 57, 286603, tzinfo=timezone.utc),
+            audit.create_time(),
+        )
+
+    def test_serialize_datetime(self):
+        create_time = datetime(
+            2024,
+            4,
+            5,
+            18,
+            10,
+            35,
+            218000,
+            tzinfo=timezone(timedelta(hours=8)),
+        )
+        last_modified_time = datetime(
+            2024,
+            4,
+            6,
+            19,
+            20,
+            45,
+            123000,
+            tzinfo=timezone(timedelta(hours=8)),
+        )
+        audit = AuditDTO(
+            _creator="alice",
+            _create_time=create_time,
+            _last_modifier="bob",
+            _last_modified_time=last_modified_time,
+        )
+
+        self.assertEqual(
+            datetime(2024, 4, 5, 10, 10, 35, 218000, tzinfo=timezone.utc),
+            audit.create_time(),
+        )
+        self.assertEqual(
+            datetime(2024, 4, 6, 11, 20, 45, 123000, tzinfo=timezone.utc),
+            audit.last_modified_time(),
+        )
+
+        serialized = audit.to_dict()
+        self.assertEqual("2024-04-05T10:10:35.218000Z", 
serialized["createTime"])
+        self.assertEqual("2024-04-06T11:20:45.123000Z", 
serialized["lastModifiedTime"])
+
+    def test_construct_with_iso_datetime(self):
+        audit = AuditDTO(
+            _creator="alice",
+            _create_time="2024-04-05T10:10:35.218Z",
+        )
+
+        self.assertEqual(
+            datetime(2024, 4, 5, 10, 10, 35, 218000, tzinfo=timezone.utc),
+            audit.create_time(),
+        )
+
+    def test_none_fields(self):
+        audit = AuditDTO()
+
+        self.assertIsNone(audit.creator())
+        self.assertIsNone(audit.create_time())
+        self.assertIsNone(audit.last_modifier())
+        self.assertIsNone(audit.last_modified_time())
+
+        serialized = audit.to_dict()
+        self.assertIsNone(serialized["creator"])
+        self.assertIsNone(serialized["createTime"])
+        self.assertIsNone(serialized["lastModifier"])
+        self.assertIsNone(serialized["lastModifiedTime"])
+
+    def test_invalid_datetime(self):
+        with self.assertRaises(ValueError):
+            
AuditDTO.from_json('{"creator":"alice","createTime":"not-a-datetime"}')
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 d6a73ea5df..f173c2606e 100644
--- a/clients/client-python/tests/unittests/dto/test_tag_dto.py
+++ b/clients/client-python/tests/unittests/dto/test_tag_dto.py
@@ -18,12 +18,15 @@ 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 = (
@@ -35,7 +38,7 @@ class TestTagDTO(unittest.TestCase):
                     "key2": "value2",
                 }
             )
-            .audit_info(AuditDTO("test_user", 1640995200000))
+            .audit_info(AuditDTO("test_user", self.AUDIT_TIME))
             .inherited(True)
             .build()
         )
@@ -46,7 +49,7 @@ class TestTagDTO(unittest.TestCase):
         self.assertEqual(deser_dict["properties"], {"key1": "value1", "key2": 
"value2"})
         self.assertTrue(deser_dict["inherited"])
         self.assertEqual(deser_dict["audit"]["creator"], "test_user")
-        self.assertEqual(deser_dict["audit"]["createTime"], 1640995200000)
+        self.assertEqual(deser_dict["audit"]["createTime"], 
"2022-01-01T00:00:00Z")
 
     def test_equality_and_hash(self):
         builder = TagDTO.builder()
@@ -59,7 +62,7 @@ class TestTagDTO(unittest.TestCase):
                     "key2": "value2",
                 }
             )
-            .audit_info(AuditDTO("test_user", 1640995200000))
+            .audit_info(AuditDTO("test_user", self.AUDIT_TIME))
             .inherited(True)
             .build()
         )
@@ -72,7 +75,7 @@ class TestTagDTO(unittest.TestCase):
                     "key2": "value2",
                 }
             )
-            .audit_info(AuditDTO("test_user", 1640995200000))
+            .audit_info(AuditDTO("test_user", self.AUDIT_TIME))
             .inherited(True)
             .build()
         )
@@ -85,7 +88,7 @@ class TestTagDTO(unittest.TestCase):
                     "key2": "value3",
                 }
             )
-            .audit_info(AuditDTO("test_user", 1640995200000))
+            .audit_info(AuditDTO("test_user", self.AUDIT_TIME))
             .inherited(False)
             .build()
         )
diff --git a/clients/client-python/tests/unittests/test_relational_table.py 
b/clients/client-python/tests/unittests/test_relational_table.py
index 7b20c1ea7f..429b3b5c59 100644
--- a/clients/client-python/tests/unittests/test_relational_table.py
+++ b/clients/client-python/tests/unittests/test_relational_table.py
@@ -17,6 +17,7 @@
 
 import json
 import unittest
+from datetime import datetime
 from http.client import HTTPResponse
 from typing import cast
 from unittest.mock import Mock, patch
@@ -177,7 +178,7 @@ class TestRelationalTable(unittest.TestCase):
     def test_get_audit_info(self):
         audit_info = self.relational_table.audit_info()
         self.assertEqual(audit_info.creator(), "Apache Gravitino")
-        self.assertEqual(audit_info.create_time(), "2025-10-10T00:00:00")
+        self.assertEqual(audit_info.create_time(), datetime(2025, 10, 10))
 
     def test_get_properties(self):
         properties = self.relational_table.properties()

Reply via email to