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