This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new ce76984da0 [python] Support tag time_retained (TTL) on
FileSystemCatalog (#8319)
ce76984da0 is described below
commit ce76984da0ffc815e10c57c0291c9d63b63f77d4
Author: chaoyang <[email protected]>
AuthorDate: Wed Jun 24 14:46:36 2026 +0800
[python] Support tag time_retained (TTL) on FileSystemCatalog (#8319)
`FileSystemCatalog.create_tag` rejected `time_retained` with
`NotImplementedError`, and the Python `Tag` only inherited `Snapshot`
fields, so `get_tag` and the `$tags` system table could only return
`None` for create-time / TTL.
This implements real tag `time_retained` support on the FileSystem path,
persisting `tagCreateTime` / `tagTimeRetained` in the **same on-disk
JSON shape as Java** (`org.apache.paimon.tag.Tag`) so tag files
round-trip across the Java and Python SDKs.
---
.../pypaimon/catalog/filesystem_catalog.py | 45 +--
paimon-python/pypaimon/common/json_util.py | 36 ++-
paimon-python/pypaimon/common/time_utils.py | 204 ++++++++++++++
paimon-python/pypaimon/table/file_store_table.py | 18 +-
paimon-python/pypaimon/table/system/tags_table.py | 11 +-
paimon-python/pypaimon/tag/tag.py | 72 ++++-
paimon-python/pypaimon/tag/tag_manager.py | 49 +++-
.../pypaimon/tests/filesystem_catalog_tag_test.py | 71 ++++-
.../pypaimon/tests/system/tags_table_test.py | 17 +-
paimon-python/pypaimon/tests/tag_ttl_serde_test.py | 311 +++++++++++++++++++++
10 files changed, 788 insertions(+), 46 deletions(-)
diff --git a/paimon-python/pypaimon/catalog/filesystem_catalog.py
b/paimon-python/pypaimon/catalog/filesystem_catalog.py
index b7356b4595..5c85c27ca8 100644
--- a/paimon-python/pypaimon/catalog/filesystem_catalog.py
+++ b/paimon-python/pypaimon/catalog/filesystem_catalog.py
@@ -37,6 +37,7 @@ from pypaimon.common.options.config import CatalogOptions
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.file_io import FileIO
from pypaimon.common.identifier import Identifier
+from pypaimon.common.time_utils import duration_to_iso8601,
local_datetime_to_system_zone_millis
from pypaimon.filesystem.caching_file_io import CachingFileIO
from pypaimon.schema.schema_change import SchemaChange
from pypaimon.schema.schema_manager import SchemaManager
@@ -405,21 +406,18 @@ class FileSystemCatalog(Catalog):
time_retained: Optional[str] = None,
ignore_if_exists: bool = False,
) -> None:
- if time_retained is not None:
- # Python's Tag dataclass does not yet carry tag_create_time /
- # tag_time_retained fields; supporting TTL on FileSystemCatalog
- # requires extending Tag + TagManager and is tracked as a
- # follow-up. Raise here instead of silently dropping the option,
- # so callers cannot mistakenly believe the TTL took effect.
- raise NotImplementedError(
- "FileSystemCatalog does not yet support `time_retained` on "
- "create_tag (requires extending the Python Tag dataclass + "
- "TagManager).")
if not isinstance(identifier, Identifier):
identifier = Identifier.from_string(identifier)
table = self.get_table(identifier)
try:
- table.create_tag(tag_name, snapshot_id, ignore_if_exists)
+ # Keyword args: FileStoreTable.create_tag orders time_retained
after
+ # ignore_if_exists (Catalog orders it before), so pass by name.
+ table.create_tag(
+ tag_name,
+ snapshot_id,
+ ignore_if_exists=ignore_if_exists,
+ time_retained=time_retained,
+ )
except ValueError as e:
# ``table.create_tag`` honors ``ignore_if_exists`` internally, so
# any "already exists" message that bubbles up here means the
@@ -453,16 +451,27 @@ class FileSystemCatalog(Catalog):
tag = table.tag_manager().get(tag_name)
if tag is None:
raise TagNotExistException(tag_name)
- # tag_create_time / tag_time_retained are not tracked on the
- # filesystem side yet — the Python Tag dataclass inherits only
- # Snapshot fields. Returning ``None`` for both keeps the response
- # shape compatible with the Java contract while making the gap
- # visible to callers.
+ # Surface tag_create_time as epoch millis and tag_time_retained as an
+ # ISO-8601 duration string (types match the Java REST GetTagResponse
+ # Long / String). The create-time is converted with the host's system
+ # default time zone, mirroring the Java REST path
+ # (RESTFileSystemCatalog#getTag uses
+ #
tagCreateTime.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()).
+ # This intentionally differs from the ``$tags`` system table, which is
a
+ # zone-less Timestamp; both follow their respective Java conversions.
+ # Both fields are None for tags created without a retention
+ # (plain-snapshot tag files).
return GetTagResponse(
tag_name=tag_name,
snapshot=tag.trim_to_snapshot(),
- tag_create_time=None,
- tag_time_retained=None,
+ tag_create_time=(
+ None if tag.tag_create_time is None
+ else local_datetime_to_system_zone_millis(tag.tag_create_time)
+ ),
+ tag_time_retained=(
+ None if tag.tag_time_retained is None
+ else duration_to_iso8601(tag.tag_time_retained)
+ ),
)
def list_tags_paged(
diff --git a/paimon-python/pypaimon/common/json_util.py
b/paimon-python/pypaimon/common/json_util.py
index 6b06f88805..de1421d7ad 100644
--- a/paimon-python/pypaimon/common/json_util.py
+++ b/paimon-python/pypaimon/common/json_util.py
@@ -32,6 +32,26 @@ def optional_json_field(json_name: str, json_include: str):
return field(metadata={"json_name": json_name, "json_include":
json_include}, default=None)
+def json_field_with_codec(json_name: str, encoder, decoder, json_include: str
= "non_null"):
+ """Create an optional field with a custom JSON name and value codec.
+
+ ``encoder`` maps the Python value to its JSON representation on
serialization;
+ ``decoder`` maps the JSON representation back to the Python value on
+ deserialization. Both are only invoked for non-``None`` values. This is
used
+ for fields whose on-disk JSON shape must match a non-trivial Java/Jackson
+ encoding (e.g. ``LocalDateTime`` arrays, ``Duration`` decimal seconds).
+ """
+ return field(
+ metadata={
+ "json_name": json_name,
+ "json_include": json_include,
+ "encoder": encoder,
+ "decoder": decoder,
+ },
+ default=None,
+ )
+
+
class JSON:
@staticmethod
@@ -65,6 +85,12 @@ class JSON:
if field_info.metadata.get("json_include", None) == "non_null":
continue
+ # Custom value codec (e.g. Java LocalDateTime / Duration encodings)
+ encoder = field_info.metadata.get("encoder")
+ if encoder is not None and field_value is not None:
+ result[json_name] = encoder(field_value)
+ continue
+
# Handle nested objects
if hasattr(field_value, "to_dict"):
result[json_name] = field_value.to_dict()
@@ -93,9 +119,13 @@ class JSON:
# Create field name mapping (json_name -> field_name)
field_mapping = {}
type_mapping = {}
+ decoder_mapping = {}
for field_info in fields(target_class):
json_name = field_info.metadata.get("json_name", field_info.name)
field_mapping[json_name] = field_info.name
+ decoder = field_info.metadata.get("decoder")
+ if decoder is not None:
+ decoder_mapping[json_name] = decoder
origin_type = getattr(field_info.type, '__origin__', None)
args = getattr(field_info.type, '__args__', None)
field_type = field_info.type
@@ -113,7 +143,11 @@ class JSON:
for json_name, value in data.items():
if json_name in field_mapping:
field_name = field_mapping[json_name]
- if json_name in type_mapping:
+ if json_name in decoder_mapping:
+ kwargs[field_name] = (
+ None if value is None else
decoder_mapping[json_name](value)
+ )
+ elif json_name in type_mapping:
tp = getattr(type_mapping[json_name], '__origin__', None)
if tp in (list, List):
item_type = getattr(type_mapping[json_name],
'__args__', None)[0]
diff --git a/paimon-python/pypaimon/common/time_utils.py
b/paimon-python/pypaimon/common/time_utils.py
index 763f1bc5f2..e88003ba2c 100644
--- a/paimon-python/pypaimon/common/time_utils.py
+++ b/paimon-python/pypaimon/common/time_utils.py
@@ -15,6 +15,11 @@
# specific language governing permissions and limitations
# under the License.
+import calendar
+from datetime import datetime, timedelta
+from typing import List
+
+
def parse_duration(text: str) -> int:
if text is None:
raise ValueError("text cannot be None")
@@ -77,3 +82,202 @@ def parse_duration(text: str) -> int:
raise ValueError(f"Duration cannot be negative: {text}")
return result_ms_int
+
+
+# Unit label -> nanoseconds. Mirrors the aliases accepted by ``parse_duration``
+# (and Java ``TimeUtils.parseDuration``); a bare number with no unit is treated
+# as milliseconds, matching ``parse_duration``.
+_UNIT_TO_NANOS = {
+ 'ns': 1, 'nano': 1, 'nanosecond': 1, 'nanoseconds': 1,
+ 'µs': 1_000, 'micro': 1_000, 'microsecond': 1_000, 'microseconds': 1_000,
+ 'ms': 1_000_000, 'milli': 1_000_000, 'millisecond': 1_000_000,
'milliseconds': 1_000_000,
+ 's': 1_000_000_000, 'sec': 1_000_000_000, 'second': 1_000_000_000,
'seconds': 1_000_000_000,
+ 'm': 60_000_000_000, 'min': 60_000_000_000, 'minute': 60_000_000_000,
'minutes': 60_000_000_000,
+ 'h': 3_600_000_000_000, 'hour': 3_600_000_000_000, 'hours':
3_600_000_000_000,
+ 'd': 86_400_000_000_000, 'day': 86_400_000_000_000, 'days':
86_400_000_000_000,
+}
+
+
+def parse_duration_nanos(text: str) -> int:
+ """Parse a duration string to an integer nanosecond count.
+
+ This is the full-precision integer counterpart of :func:`parse_duration`
+ (which returns rounded milliseconds): it accepts the same unit aliases and
+ the same "bare number means milliseconds" convention, but keeps
sub-millisecond
+ units exactly (``"1ns"`` -> ``1``, ``"500micro"`` -> ``500_000``) instead
of
+ rounding them to zero. Use it where sub-millisecond precision must not be
+ silently dropped. ``parse_duration`` is intentionally left untouched so its
+ millisecond contract (relied on by option parsing) does not change.
+ """
+ if text is None:
+ raise ValueError("text cannot be None")
+
+ trimmed = text.strip().lower()
+ if not trimmed:
+ raise ValueError("argument is an empty- or whitespace-only string")
+
+ pos = 0
+ while pos < len(trimmed) and trimmed[pos].isdigit():
+ pos += 1
+
+ number_str = trimmed[:pos]
+ unit_str = trimmed[pos:].strip()
+
+ if not number_str:
+ raise ValueError("text does not start with a number")
+
+ try:
+ value = int(number_str)
+ except ValueError:
+ raise ValueError(
+ f"The value '{number_str}' cannot be re represented as 64bit
number (numeric overflow)."
+ )
+
+ if not unit_str:
+ nanos_per_unit = 1_000_000 # bare number is milliseconds
+ elif unit_str in _UNIT_TO_NANOS:
+ nanos_per_unit = _UNIT_TO_NANOS[unit_str]
+ else:
+ supported_units = (
+ 'DAYS: (d | day | days), '
+ 'HOURS: (h | hour | hours), '
+ 'MINUTES: (m | min | minute | minutes), '
+ 'SECONDS: (s | sec | second | seconds), '
+ 'MILLISECONDS: (ms | milli | millisecond | milliseconds), '
+ 'MICROSECONDS: (µs | micro | microsecond | microseconds), '
+ 'NANOSECONDS: (ns | nano | nanosecond | nanoseconds)'
+ )
+ raise ValueError(
+ f"Time interval unit label '{unit_str}' does not match any of the
recognized units: "
+ f"{supported_units}"
+ )
+
+ return value * nanos_per_unit
+
+
+# ---------------------------------------------------------------------------
+# Codecs for Java temporal types as serialized by Jackson's JavaTimeModule.
+#
+# These mirror the on-disk JSON shapes that Paimon's Java side reads/writes so
+# that a tag file written by pypaimon is interoperable with Java and vice versa
+# (see ``org.apache.paimon.tag.Tag`` and ``TagTest``):
+# - ``LocalDateTime`` -> JSON array ``[year, month, day, hour, minute,
+# second, nanoOfSecond]``
+# - ``Duration`` -> JSON number of (fractional) seconds, e.g.
``86400.0``
+#
+# Resolution note: Python's ``datetime`` / ``timedelta`` are microsecond-based,
+# so a Java create-time or duration finer than a microsecond is truncated to
+# microseconds on read. Tag granularity is coarse, so this is not a concern in
+# practice.
+# ---------------------------------------------------------------------------
+
+_NANOS_PER_MICRO = 1000
+
+
+def local_datetime_to_json_array(dt: datetime) -> List[int]:
+ """Encode a naive ``datetime`` as Java ``LocalDateTime`` array form.
+
+ Matches Jackson's ``LocalDateTimeSerializer`` byte-for-byte: it always
emits
+ ``[year, month, day, hour, minute]``, appends ``second`` only when second
or
+ nanoOfSecond is non-zero, and appends ``nanoOfSecond`` only when it is
+ non-zero -- i.e. trailing zero components are omitted.
``json_array_to_local_datetime``
+ pads them back, so a shorter array round-trips. Python ``datetime`` only
has
+ microsecond resolution, so the emitted nanoOfSecond is ``microsecond *
1000``
+ (never finer than microseconds).
+ """
+ nano = dt.microsecond * _NANOS_PER_MICRO
+ arr = [dt.year, dt.month, dt.day, dt.hour, dt.minute]
+ if dt.second != 0 or nano != 0:
+ arr.append(dt.second)
+ if nano != 0:
+ arr.append(nano)
+ return arr
+
+
+def json_array_to_local_datetime(arr: List[int]) -> datetime:
+ """Decode a Java ``LocalDateTime`` array form into a naive ``datetime``.
+
+ Jackson omits trailing zero components, so the array may be shorter than 7;
+ missing components default to 0. A Java nanoOfSecond finer than a
+ microsecond is truncated to microseconds (Python's resolution limit).
+ """
+ padded = list(arr) + [0] * (7 - len(arr))
+ year, month, day, hour, minute, second, nano = padded[:7]
+ return datetime(
+ year, month, day, hour, minute, second, nano // _NANOS_PER_MICRO
+ )
+
+
+def duration_to_json_seconds(td: timedelta):
+ """Encode a ``timedelta`` as Java ``Duration`` decimal-seconds number."""
+ return td.total_seconds()
+
+
+def json_seconds_to_duration(seconds) -> timedelta:
+ """Decode a Java ``Duration`` decimal-seconds number into a
``timedelta``."""
+ return timedelta(seconds=seconds)
+
+
+def duration_to_iso8601(td: timedelta) -> str:
+ """Render a non-negative ``timedelta`` like
``java.time.Duration.toString()``.
+
+ Examples: 1 day -> ``PT24H`` (Duration has no day unit), 30 min ->
+ ``PT30M``, 5 s -> ``PT5S``, zero -> ``PT0S``. Matches what Paimon's Java
+ ``$tags`` system table surfaces for ``time_retained``. Retentions only ever
+ come from ``parse_duration``, which rejects negatives, so only the
+ non-negative form is supported.
+ """
+ total_micros = (
+ td.days * 86_400_000_000 + td.seconds * 1_000_000 + td.microseconds
+ )
+ if total_micros <= 0:
+ return "PT0S"
+
+ total_seconds, micros = divmod(total_micros, 1_000_000)
+ hours, rem = divmod(total_seconds, 3600)
+ minutes, secs = divmod(rem, 60)
+
+ buf = "PT"
+ if hours != 0:
+ buf += "{}H".format(hours)
+ if minutes != 0:
+ buf += "{}M".format(minutes)
+ if secs != 0 or micros != 0 or buf == "PT":
+ frac = ""
+ if micros != 0:
+ frac = "." + ("%06d" % micros).rstrip("0")
+ buf += "{}{}S".format(secs, frac)
+ return buf
+
+
+def local_datetime_to_millis(dt: datetime) -> int:
+ """Convert a naive ``LocalDateTime`` to epoch millis, treating it as UTC.
+
+ This is the zone-less / wall-clock conversion: it mirrors Java
+ ``Timestamp.fromLocalDateTime`` and feeds the ``$tags`` system table's
+ ``create_time`` (a ``TIMESTAMP`` column, which is itself zone-less), so the
+ result does NOT depend on the host's time zone. For the REST
+ ``GetTagResponse`` epoch-millis ``Long`` use
+ :func:`local_datetime_to_system_zone_millis` instead. Uses integer math
with
+ floored sub-millisecond truncation (matching Java
``Instant.toEpochMilli``),
+ so it is exact and correct for pre-epoch instants too.
+ """
+ return calendar.timegm(dt.timetuple()) * 1000 + dt.microsecond // 1000
+
+
+def local_datetime_to_system_zone_millis(dt: datetime) -> int:
+ """Convert a naive ``LocalDateTime`` to epoch millis in the host's default
+ time zone.
+
+ Mirrors Java ``LocalDateTime.atZone(ZoneId.systemDefault()).toInstant()
+ .toEpochMilli()`` -- the conversion the REST ``GetTagResponse`` applies to
+ ``tagCreateTime`` (see ``RESTFileSystemCatalog#getTag``). Unlike
+ :func:`local_datetime_to_millis` (zone-less / UTC, used by the ``$tags``
+ Timestamp column), the result depends on the host's local time zone, so the
+ two intentionally differ on non-UTC hosts -- exactly as the two Java paths
do.
+ """
+ # A naive datetime.timestamp() interprets dt in the system local zone (via
+ # mktime). Take whole seconds first to avoid float rounding, then floor the
+ # sub-second part to millis to match Instant.toEpochMilli().
+ epoch_seconds = int(dt.replace(microsecond=0).timestamp())
+ return epoch_seconds * 1000 + dt.microsecond // 1000
diff --git a/paimon-python/pypaimon/table/file_store_table.py
b/paimon-python/pypaimon/table/file_store_table.py
index 8805806883..dd561eb55b 100644
--- a/paimon-python/pypaimon/table/file_store_table.py
+++ b/paimon-python/pypaimon/table/file_store_table.py
@@ -160,7 +160,8 @@ class FileStoreTable(Table):
self,
tag_name: str,
snapshot_id: Optional[int] = None,
- ignore_if_exists: bool = False
+ ignore_if_exists: bool = False,
+ time_retained: Optional[str] = None
) -> None:
"""
Create a tag for a snapshot.
@@ -169,6 +170,8 @@ class FileStoreTable(Table):
tag_name: Name for the tag
snapshot_id: ID of the snapshot to tag. If None, uses the latest
snapshot.
ignore_if_exists: If True, don't raise error if tag already exists
+ time_retained: Optional retention (e.g. ``"1d"``); when set, the
tag
+ carries a create-time and TTL.
Raises:
ValueError: If no snapshot exists or tag already exists (when
ignore_if_exists=False)
@@ -186,7 +189,7 @@ class FileStoreTable(Table):
raise ValueError("No snapshot exists in this table.")
tag_mgr = self.tag_manager()
- tag_mgr.create_tag(snapshot, tag_name, ignore_if_exists)
+ tag_mgr.create_tag(snapshot, tag_name, ignore_if_exists, time_retained)
def delete_tag(self, tag_name: str) -> bool:
"""
@@ -335,7 +338,12 @@ class FileStoreTable(Table):
tag_mgr = self.tag_manager()
tag_mgr.rename_tag(old_name, new_name)
- def replace_tag(self, tag_name: str, snapshot_id: int = None) -> None:
+ def replace_tag(
+ self,
+ tag_name: str,
+ snapshot_id: int = None,
+ time_retained: Optional[str] = None
+ ) -> None:
"""
Replace an existing tag with a new snapshot.
@@ -343,6 +351,8 @@ class FileStoreTable(Table):
tag_name: Name of the tag to replace
snapshot_id: The snapshot id to associate with the tag.
If None, uses the latest snapshot.
+ time_retained: Optional retention (e.g. ``"1d"``); when set, the
+ replaced tag carries a create-time and TTL.
Raises:
ValueError: If tag doesn't exist, or snapshot doesn't exist
@@ -355,7 +365,7 @@ class FileStoreTable(Table):
snapshot = self.snapshot_manager().get_snapshot_by_id(snapshot_id)
if snapshot is None:
raise ValueError(f"Snapshot id '{snapshot_id}' doesn't exist.")
- self.tag_manager().replace_tag(snapshot, tag_name)
+ self.tag_manager().replace_tag(snapshot, tag_name, time_retained)
def path_factory(self) -> 'FileStorePathFactory':
from pypaimon.utils.file_store_path_factory import FileStorePathFactory
diff --git a/paimon-python/pypaimon/table/system/tags_table.py
b/paimon-python/pypaimon/table/system/tags_table.py
index 7cc50ec7a9..dc9bc18636 100644
--- a/paimon-python/pypaimon/table/system/tags_table.py
+++ b/paimon-python/pypaimon/table/system/tags_table.py
@@ -21,6 +21,7 @@ from typing import List, Optional
import pyarrow
+from pypaimon.common.time_utils import duration_to_iso8601,
local_datetime_to_millis
from pypaimon.schema.data_types import AtomicType, DataField, RowType
from pypaimon.table.system.system_table import SystemTable
@@ -73,10 +74,12 @@ class TagsTable(SystemTable):
record_counts.append(
None if tag.total_record_count is None
else int(tag.total_record_count))
- # TODO: surface create_time and time_retained once the Tag
- # dataclass carries them.
- create_times.append(None)
- time_retained.append(None)
+ create_times.append(
+ None if tag.tag_create_time is None
+ else local_datetime_to_millis(tag.tag_create_time))
+ time_retained.append(
+ None if tag.tag_time_retained is None
+ else duration_to_iso8601(tag.tag_time_retained))
return pyarrow.table({
"tag_name": pyarrow.array(names, type=pyarrow.string()),
diff --git a/paimon-python/pypaimon/tag/tag.py
b/paimon-python/pypaimon/tag/tag.py
index d9911a16dd..d793839374 100644
--- a/paimon-python/pypaimon/tag/tag.py
+++ b/paimon-python/pypaimon/tag/tag.py
@@ -15,10 +15,76 @@
# specific language governing permissions and limitations
# under the License.
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from typing import Optional
+
+from pypaimon.common.json_util import json_field_with_codec
+from pypaimon.common.time_utils import (
+ duration_to_json_seconds,
+ json_array_to_local_datetime,
+ json_seconds_to_duration,
+ local_datetime_to_json_array,
+)
from pypaimon.snapshot.snapshot import Snapshot
+@dataclass
class Tag(Snapshot):
+ """A Snapshot with optional ``tagCreateTime`` and ``tagTimeRetained``
(TTL).
+
+ Both fields mirror Java ``org.apache.paimon.tag.Tag`` and are serialized in
+ the same on-disk JSON shape (LocalDateTime as a ``[y, mo, d, h, mi, s,
ns]``
+ array, Duration as decimal seconds), so tag files round-trip across the
Java
+ and Python SDKs. They are ``None`` for tags created without a retention.
+ """
+
+ tag_create_time: Optional[datetime] = json_field_with_codec(
+ "tagCreateTime",
+ encoder=local_datetime_to_json_array,
+ decoder=json_array_to_local_datetime,
+ )
+ tag_time_retained: Optional[timedelta] = json_field_with_codec(
+ "tagTimeRetained",
+ encoder=duration_to_json_seconds,
+ decoder=json_seconds_to_duration,
+ )
+
+ @staticmethod
+ def from_snapshot_and_tag_ttl(
+ snapshot: Snapshot,
+ tag_time_retained: Optional[timedelta],
+ tag_create_time: datetime,
+ ) -> "Tag":
+ """Build a Tag from a Snapshot plus tag-specific TTL metadata.
+
+ Mirrors Java ``Tag.fromSnapshotAndTagTtl``.
+ """
+ return Tag(
+ version=snapshot.version,
+ id=snapshot.id,
+ schema_id=snapshot.schema_id,
+ base_manifest_list=snapshot.base_manifest_list,
+ delta_manifest_list=snapshot.delta_manifest_list,
+ total_record_count=snapshot.total_record_count,
+ delta_record_count=snapshot.delta_record_count,
+ commit_user=snapshot.commit_user,
+ commit_identifier=snapshot.commit_identifier,
+ commit_kind=snapshot.commit_kind,
+ time_millis=snapshot.time_millis,
+ base_manifest_list_size=snapshot.base_manifest_list_size,
+ delta_manifest_list_size=snapshot.delta_manifest_list_size,
+ changelog_manifest_list=snapshot.changelog_manifest_list,
+ changelog_manifest_list_size=snapshot.changelog_manifest_list_size,
+ index_manifest=snapshot.index_manifest,
+ changelog_record_count=snapshot.changelog_record_count,
+ watermark=snapshot.watermark,
+ statistics=snapshot.statistics,
+ next_row_id=snapshot.next_row_id,
+ properties=snapshot.properties,
+ tag_create_time=tag_create_time,
+ tag_time_retained=tag_time_retained,
+ )
def trim_to_snapshot(self) -> Snapshot:
"""Convert this Tag to a Snapshot"""
@@ -34,10 +100,14 @@ class Tag(Snapshot):
commit_identifier=self.commit_identifier,
commit_kind=self.commit_kind,
time_millis=self.time_millis,
+ base_manifest_list_size=self.base_manifest_list_size,
+ delta_manifest_list_size=self.delta_manifest_list_size,
changelog_manifest_list=self.changelog_manifest_list,
+ changelog_manifest_list_size=self.changelog_manifest_list_size,
index_manifest=self.index_manifest,
changelog_record_count=self.changelog_record_count,
watermark=self.watermark,
statistics=self.statistics,
- next_row_id=self.next_row_id
+ next_row_id=self.next_row_id,
+ properties=self.properties,
)
diff --git a/paimon-python/pypaimon/tag/tag_manager.py
b/paimon-python/pypaimon/tag/tag_manager.py
index 45a1810414..96f4eead8e 100644
--- a/paimon-python/pypaimon/tag/tag_manager.py
+++ b/paimon-python/pypaimon/tag/tag_manager.py
@@ -17,10 +17,12 @@
import logging
import os
+from datetime import datetime, timedelta
from typing import Optional
from pypaimon.common.file_io import FileIO
from pypaimon.common.json_util import JSON
+from pypaimon.common.time_utils import parse_duration_nanos
from pypaimon.snapshot.snapshot import Snapshot
from pypaimon.tag.tag import Tag
@@ -118,16 +120,19 @@ class TagManager:
self,
snapshot: Snapshot,
tag_name: str,
- ignore_if_exists: bool = False
+ ignore_if_exists: bool = False,
+ time_retained: Optional[str] = None
) -> None:
"""
Create a tag from given snapshot and save it in the storage.
-
+
Args:
snapshot: The snapshot to tag
tag_name: Name for the tag
ignore_if_exists: If True, don't raise error if tag already exists
-
+ time_retained: Optional retention (e.g. ``"1d"``, ``"12h"``); when
+ set, the tag carries a create-time and TTL.
+
Raises:
ValueError: If tag_name is blank or tag already exists (when
ignore_if_exists=False)
"""
@@ -139,7 +144,7 @@ class TagManager:
return
raise ValueError(f"Tag '{tag_name}' already exists.")
- self._create_or_replace_tag(snapshot, tag_name)
+ self._create_or_replace_tag(snapshot, tag_name, time_retained)
def list_tags(self):
"""List all tags."""
@@ -161,7 +166,8 @@ class TagManager:
def _create_or_replace_tag(
self,
snapshot: Snapshot,
- tag_name: str
+ tag_name: str,
+ time_retained: Optional[str] = None
) -> None:
"""
Internal method to create or replace a tag.
@@ -173,7 +179,27 @@ class TagManager:
if not self.file_io.exists(tag_dir):
self.file_io.mkdirs(tag_dir)
- content = JSON.to_json(snapshot)
+ # Mirror Java TagManager.createOrReplaceTag: when no retention is set,
+ # write the plain Snapshot JSON (no tag-specific fields) so the file
+ # stays readable by older readers. Only write the richer Tag JSON when
a
+ # retention (and thus a create-time) is present.
+ if time_retained is not None:
+ # parse_duration_nanos keeps sub-millisecond units exactly.
Python's
+ # timedelta is microsecond-resolution, so reject sub-microsecond
+ # retentions (e.g. "1ns") instead of silently writing a zero-TTL
tag.
+ nanos = parse_duration_nanos(time_retained)
+ micros, sub_micro = divmod(nanos, 1000)
+ if sub_micro:
+ raise ValueError(
+ f"time_retained '{time_retained}' specifies
sub-microsecond "
+ f"precision, which pypaimon cannot represent (Python
timedelta "
+ f"resolution is microseconds). Use a value of at least 1
microsecond."
+ )
+ retained = timedelta(microseconds=micros)
+ tag = Tag.from_snapshot_and_tag_ttl(snapshot, retained,
datetime.now())
+ content = JSON.to_json(tag)
+ else:
+ content = JSON.to_json(snapshot)
self.file_io.overwrite_file_utf8(tag_path, content)
@@ -199,13 +225,20 @@ class TagManager:
self.file_io.delete_quietly(path)
return True
- def replace_tag(self, snapshot: Snapshot, tag_name: str) -> None:
+ def replace_tag(
+ self,
+ snapshot: Snapshot,
+ tag_name: str,
+ time_retained: Optional[str] = None
+ ) -> None:
"""
Replace an existing tag with a new snapshot.
Args:
snapshot: The new snapshot to associate with the tag
tag_name: Name of the tag to replace
+ time_retained: Optional retention (e.g. ``"1d"``); when set, the
+ replaced tag carries a create-time and TTL.
Raises:
ValueError: If tag_name is blank or tag doesn't exist
@@ -216,7 +249,7 @@ class TagManager:
if not self.tag_exists(tag_name):
raise ValueError(f"Tag '{tag_name}' doesn't exist.")
- self._create_or_replace_tag(snapshot, tag_name)
+ self._create_or_replace_tag(snapshot, tag_name, time_retained)
def rename_tag(self, old_name: str, new_name: str) -> None:
"""
diff --git a/paimon-python/pypaimon/tests/filesystem_catalog_tag_test.py
b/paimon-python/pypaimon/tests/filesystem_catalog_tag_test.py
index a721dec3d4..e7dc0419cd 100644
--- a/paimon-python/pypaimon/tests/filesystem_catalog_tag_test.py
+++ b/paimon-python/pypaimon/tests/filesystem_catalog_tag_test.py
@@ -24,6 +24,7 @@ that the catalog layer must produce regardless of which
catalog
implementation is in use.
"""
+import json
import os
import shutil
import tempfile
@@ -81,12 +82,21 @@ class FileSystemCatalogTagCRUDTest(unittest.TestCase):
self.assertIsInstance(response, GetTagResponse)
self.assertEqual(response.tag_name, "t1")
self.assertIsInstance(response.snapshot, Snapshot)
- # FileSystemCatalog does not yet track tag_create_time /
- # tag_time_retained — both must be None until the underlying Tag
- # dataclass is extended.
+ # A tag created without a retention carries no create-time / TTL: the
+ # file is a plain Snapshot JSON, so both surface as None.
self.assertIsNone(response.tag_create_time)
self.assertIsNone(response.tag_time_retained)
+ def test_create_tag_without_ttl_writes_plain_snapshot(self):
+ # No-TTL tags must be written as plain Snapshot JSON (no tag-specific
+ # fields) so the file stays readable by older / Java readers.
+ self.catalog.create_tag(self.identifier, "t1")
+ table = self.catalog.get_table(self.identifier)
+ tag_path = table.tag_manager().tag_path("t1")
+ content = json.loads(table.file_io.read_file_utf8(tag_path))
+ self.assertNotIn("tagCreateTime", content)
+ self.assertNotIn("tagTimeRetained", content)
+
def test_create_tag_with_snapshot_id(self):
self.catalog.create_tag(self.identifier, "t1", snapshot_id=1)
response = self.catalog.get_tag(self.identifier, "t1")
@@ -109,11 +119,46 @@ class FileSystemCatalogTagCRUDTest(unittest.TestCase):
result = self.catalog.list_tags_paged(self.identifier)
self.assertEqual(result.elements, ["t1"])
- def test_create_tag_with_time_retained_raises_not_implemented(self):
- with self.assertRaises(NotImplementedError) as cm:
- self.catalog.create_tag(
- self.identifier, "t1", time_retained="1d")
- self.assertIn("time_retained", str(cm.exception))
+ def test_create_tag_with_time_retained(self):
+ self.catalog.create_tag(self.identifier, "t1", time_retained="1d")
+ response = self.catalog.get_tag(self.identifier, "t1")
+ # create_time surfaces as epoch millis, time_retained as an ISO-8601
+ # duration string (matching Java's Duration.toString()).
+ self.assertIsNotNone(response.tag_create_time)
+ self.assertIsInstance(response.tag_create_time, int)
+ self.assertEqual(response.tag_time_retained, "PT24H")
+
+ def test_create_tag_with_time_retained_writes_java_compatible_json(self):
+ self.catalog.create_tag(self.identifier, "t1", time_retained="1d")
+ table = self.catalog.get_table(self.identifier)
+ tag_path = table.tag_manager().tag_path("t1")
+ content = json.loads(table.file_io.read_file_utf8(tag_path))
+ # On-disk shape must match Java: LocalDateTime array + Duration
seconds.
+ self.assertIsInstance(content["tagCreateTime"], list)
+ self.assertEqual(len(content["tagCreateTime"]), 7)
+ self.assertEqual(content["tagTimeRetained"], 86400.0)
+
+ def test_create_tag_with_sub_millisecond_time_retained_preserved(self):
+ # A sub-millisecond but microsecond-representable retention must be
kept
+ # (500us -> 0.0005s / PT0.0005S), not rounded to a zero-TTL tag.
+ self.catalog.create_tag(self.identifier, "t1",
time_retained="500micro")
+ table = self.catalog.get_table(self.identifier)
+ tag_path = table.tag_manager().tag_path("t1")
+ content = json.loads(table.file_io.read_file_utf8(tag_path))
+ self.assertEqual(content["tagTimeRetained"], 0.0005)
+ response = self.catalog.get_tag(self.identifier, "t1")
+ self.assertEqual(response.tag_time_retained, "PT0.0005S")
+
+ def test_create_tag_with_sub_microsecond_time_retained_rejected(self):
+ # A retention finer than a microsecond cannot be represented by
Python's
+ # timedelta; it must raise instead of silently writing a zero-TTL tag,
+ # and no tag file should be left behind.
+ for retained in ("1ns", "999ns"):
+ with self.assertRaises(ValueError):
+ self.catalog.create_tag(
+ self.identifier, "t1", time_retained=retained)
+ with self.assertRaises(TagNotExistException):
+ self.catalog.get_tag(self.identifier, "t1")
def test_create_tag_table_not_exists(self):
with self.assertRaises(TableNotExistException):
@@ -221,6 +266,16 @@ class FileSystemCatalogTagCRUDTest(unittest.TestCase):
tag = table.tag_manager().get("latest_test")
self.assertEqual(tag.trim_to_snapshot().id, 2)
+ def test_replace_tag_with_time_retained(self):
+ table = self.catalog.get_table(self.identifier)
+ table.create_tag("ttl_replace", snapshot_id=1)
+ # Replacing with a retention upgrades the plain-snapshot tag into a
+ # TTL-carrying tag.
+ table.replace_tag("ttl_replace", snapshot_id=1, time_retained="12h")
+ response = self.catalog.get_tag(self.identifier, "ttl_replace")
+ self.assertIsNotNone(response.tag_create_time)
+ self.assertEqual(response.tag_time_retained, "PT12H")
+
def test_replace_tag_not_exists_raises(self):
table = self.catalog.get_table(self.identifier)
with self.assertRaises(ValueError) as cm:
diff --git a/paimon-python/pypaimon/tests/system/tags_table_test.py
b/paimon-python/pypaimon/tests/system/tags_table_test.py
index bfc5af9109..e801b656d8 100644
--- a/paimon-python/pypaimon/tests/system/tags_table_test.py
+++ b/paimon-python/pypaimon/tests/system/tags_table_test.py
@@ -120,13 +120,26 @@ class TagsTableTest(unittest.TestCase):
tzinfo=datetime.timezone.utc).timestamp() * 1000)
self.assertEqual(_TAG_COMMIT_MS, ms)
- # Until pypaimon Tag carries these fields they are surfaced as
- # None — same trade-off as FileSystemCatalog.get_tag.
+ # Tags created without a retention carry no create-time / TTL.
for value in arrow_table.column("create_time").to_pylist():
self.assertIsNone(value)
for value in arrow_table.column("time_retained").to_pylist():
self.assertIsNone(value)
+ def test_surfaces_create_time_and_time_retained_for_ttl_tag(self):
+ self.table.create_tag("ttl", snapshot_id=7, time_retained="1d")
+
+ arrow_table = _read(self.catalog.get_table("db.t$tags"))
+ row = {
+ name: arrow_table.column(name).to_pylist()[0]
+ for name in arrow_table.column_names
+ }
+ self.assertEqual("ttl", row["tag_name"])
+ # create_time is a real timestamp; time_retained is the ISO-8601
+ # duration string (Java Duration.toString()).
+ self.assertIsInstance(row["create_time"], datetime.datetime)
+ self.assertEqual("PT24H", row["time_retained"])
+
if __name__ == "__main__":
unittest.main()
diff --git a/paimon-python/pypaimon/tests/tag_ttl_serde_test.py
b/paimon-python/pypaimon/tests/tag_ttl_serde_test.py
new file mode 100644
index 0000000000..23af669768
--- /dev/null
+++ b/paimon-python/pypaimon/tests/tag_ttl_serde_test.py
@@ -0,0 +1,311 @@
+# 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.
+
+"""Unit tests for Tag TTL serialization and the temporal codecs.
+
+These pin down the on-disk JSON shape against the Java side
+(``org.apache.paimon.tag.Tag`` / ``TagTest``) so tag files stay
+interoperable across the Java and Python SDKs.
+"""
+
+import dataclasses
+import json
+import os
+import time
+import unittest
+from datetime import datetime, timedelta, timezone
+
+from pypaimon.common.json_util import JSON
+from pypaimon.common.time_utils import (
+ duration_to_iso8601,
+ duration_to_json_seconds,
+ json_array_to_local_datetime,
+ json_seconds_to_duration,
+ local_datetime_to_json_array,
+ local_datetime_to_millis,
+ local_datetime_to_system_zone_millis,
+ parse_duration_nanos,
+)
+from pypaimon.snapshot.snapshot import Snapshot
+from pypaimon.tag.tag import Tag
+
+
+def _snapshot():
+ return Snapshot(
+ version=3,
+ id=0,
+ schema_id=0,
+ base_manifest_list=None,
+ delta_manifest_list=None,
+ total_record_count=0,
+ delta_record_count=0,
+ commit_user=None,
+ commit_identifier=0,
+ commit_kind="APPEND",
+ time_millis=1000,
+ )
+
+
+def _full_snapshot():
+ """A Snapshot with every optional field set to a distinctive non-None
value,
+ so a trim that silently drops any of them is caught."""
+ return Snapshot(
+ version=3,
+ id=7,
+ schema_id=2,
+ base_manifest_list="base-list",
+ delta_manifest_list="delta-list",
+ total_record_count=100,
+ delta_record_count=10,
+ commit_user="u",
+ commit_identifier=42,
+ commit_kind="APPEND",
+ time_millis=1000,
+ base_manifest_list_size=111,
+ delta_manifest_list_size=222,
+ changelog_manifest_list="changelog-list",
+ changelog_manifest_list_size=333,
+ index_manifest="index-manifest",
+ changelog_record_count=5,
+ watermark=123456789,
+ statistics="stats",
+ next_row_id=900,
+ properties={"k": "v"},
+ )
+
+
+class TemporalCodecTest(unittest.TestCase):
+
+ def test_local_datetime_array_round_trip(self):
+ dt = datetime(2024, 6, 25, 10, 30, 45, 123456)
+ arr = local_datetime_to_json_array(dt)
+ # [year, month, day, hour, minute, second, nanoOfSecond]
+ self.assertEqual([2024, 6, 25, 10, 30, 45, 123456000], arr)
+ self.assertEqual(dt, json_array_to_local_datetime(arr))
+
+ def test_local_datetime_array_omits_trailing_zero_components(self):
+ # Match Jackson's LocalDateTimeSerializer byte-for-byte: a whole-minute
+ # time emits 5 components, a whole-second time emits 6, and only a
+ # non-zero nanoOfSecond produces the 7th. All must still round-trip.
+ whole_minute = datetime(2024, 6, 25, 10, 30, 0, 0)
+ self.assertEqual([2024, 6, 25, 10, 30],
+ local_datetime_to_json_array(whole_minute))
+ self.assertEqual(whole_minute,
+ json_array_to_local_datetime([2024, 6, 25, 10, 30]))
+
+ whole_second = datetime(2024, 6, 25, 10, 30, 45, 0)
+ self.assertEqual([2024, 6, 25, 10, 30, 45],
+ local_datetime_to_json_array(whole_second))
+ self.assertEqual(whole_second,
+ json_array_to_local_datetime([2024, 6, 25, 10, 30,
45]))
+
+ def test_local_datetime_array_pads_missing_components(self):
+ # Jackson omits trailing zero components; missing ones default to 0.
+ self.assertEqual(
+ datetime(1969, 1, 1, 0, 0, 0, 0),
+ json_array_to_local_datetime([1969, 1, 1]),
+ )
+
+ def test_local_datetime_truncates_java_nanos_to_micros(self):
+ # Java nanoOfSecond (9 digits) is truncated to microseconds.
+ dt = json_array_to_local_datetime([1969, 1, 1, 0, 0, 0, 123456789])
+ self.assertEqual(123456, dt.microsecond)
+
+ def test_duration_seconds_round_trip(self):
+ td = timedelta(days=1)
+ self.assertEqual(86400.0, duration_to_json_seconds(td))
+ self.assertEqual(td, json_seconds_to_duration(86400.0))
+ # Java writes "5.000000000"; json parses it as 5.0.
+ self.assertEqual(timedelta(seconds=5), json_seconds_to_duration(5.0))
+
+ def test_duration_to_iso8601(self):
+ self.assertEqual("PT24H", duration_to_iso8601(timedelta(days=1)))
+ self.assertEqual("PT30M", duration_to_iso8601(timedelta(minutes=30)))
+ self.assertEqual("PT5S", duration_to_iso8601(timedelta(seconds=5)))
+ self.assertEqual("PT0S", duration_to_iso8601(timedelta(0)))
+ self.assertEqual(
+ "PT1H30M15S",
+ duration_to_iso8601(timedelta(hours=1, minutes=30, seconds=15)),
+ )
+ self.assertEqual(
+ "PT0.5S", duration_to_iso8601(timedelta(milliseconds=500)))
+ # Fractional seconds combined with hour/minute components.
+ self.assertEqual(
+ "PT1H30M0.5S",
+ duration_to_iso8601(
+ timedelta(hours=1, minutes=30, milliseconds=500)),
+ )
+ self.assertEqual(
+ "PT12H", duration_to_iso8601(timedelta(hours=12)))
+
+ def test_local_datetime_to_millis_pre_epoch_floors(self):
+ # Pre-1970 with sub-millisecond is floored (matching Java
+ # Instant.toEpochMilli), not truncated toward zero.
+ dt = datetime(1969, 12, 31, 23, 59, 59, 500000)
+ self.assertEqual(-500, local_datetime_to_millis(dt))
+
+ def test_local_datetime_to_millis(self):
+ dt = datetime(1970, 1, 1, 0, 0, 1)
+ self.assertEqual(1000, local_datetime_to_millis(dt))
+
+ @staticmethod
+ def _run_in_tz(tz_name, fn):
+ """Run ``fn`` with the process TZ forced to ``tz_name`` and
restored."""
+ prev = os.environ.get("TZ")
+ os.environ["TZ"] = tz_name
+ time.tzset()
+ try:
+ return fn()
+ finally:
+ if prev is None:
+ os.environ.pop("TZ", None)
+ else:
+ os.environ["TZ"] = prev
+ time.tzset()
+
+ @unittest.skipUnless(hasattr(time, "tzset"), "requires time.tzset (POSIX)")
+ def test_system_zone_millis_follows_host_timezone(self):
+ # A timezone-less create-time (Java LocalDateTime, made with the local
+ # now()). The REST GetTagResponse interprets it in the host's default
+ # zone, so the same wall-clock yields different epoch millis per TZ.
+ dt = datetime(2024, 1, 1, 12, 0, 0) # DST-free in Asia/Shanghai
(UTC+8)
+
+ sh = self._run_in_tz(
+ "Asia/Shanghai", lambda: local_datetime_to_system_zone_millis(dt))
+ utc = self._run_in_tz(
+ "UTC", lambda: local_datetime_to_system_zone_millis(dt))
+
+ # Expected values derived from tz-aware references (not hard-coded).
+ expected_sh = int(datetime(
+ 2024, 1, 1, 12, tzinfo=timezone(timedelta(hours=8))).timestamp() *
1000)
+ expected_utc = int(datetime(
+ 2024, 1, 1, 12, tzinfo=timezone.utc).timestamp() * 1000)
+ self.assertEqual(expected_sh, sh)
+ self.assertEqual(expected_utc, utc)
+ # Shanghai noon is 8h earlier in absolute time than UTC noon.
+ self.assertEqual(8 * 3600 * 1000, utc - sh)
+
+ # The zone-less $tags conversion must stay fixed regardless of host TZ.
+ zoneless = self._run_in_tz(
+ "Asia/Shanghai", lambda: local_datetime_to_millis(dt))
+ self.assertEqual(expected_utc, zoneless)
+
+ def test_parse_duration_nanos_keeps_full_precision(self):
+ # Unlike parse_duration (rounded milliseconds), the nanos variant keeps
+ # sub-millisecond units exactly instead of rounding them to zero.
+ self.assertEqual(1, parse_duration_nanos("1ns"))
+ self.assertEqual(1_000, parse_duration_nanos("1micro"))
+ self.assertEqual(500_000, parse_duration_nanos("500micro"))
+ self.assertEqual(1_000_000, parse_duration_nanos("1ms"))
+ self.assertEqual(1_000_000_000, parse_duration_nanos("1s"))
+ self.assertEqual(86_400_000_000_000, parse_duration_nanos("1d"))
+ # A bare number means milliseconds, matching parse_duration.
+ self.assertEqual(5_000_000, parse_duration_nanos("5"))
+
+ def test_parse_duration_nanos_rejects_unknown_unit(self):
+ with self.assertRaises(ValueError):
+ parse_duration_nanos("1x")
+
+ def test_sub_millisecond_retention_round_trips(self):
+ # 500 microseconds is representable and must survive serialization at
+ # microsecond precision (0.0005s / PT0.0005S), not collapse to zero.
+ td = timedelta(microseconds=parse_duration_nanos("500micro") // 1000)
+ self.assertEqual(timedelta(microseconds=500), td)
+ self.assertEqual(0.0005, duration_to_json_seconds(td))
+ self.assertEqual(td, json_seconds_to_duration(0.0005))
+ self.assertEqual("PT0.0005S", duration_to_iso8601(td))
+
+
+class TagSerdeTest(unittest.TestCase):
+
+ def test_no_ttl_serializes_as_plain_snapshot(self):
+ # A Tag without TTL must not emit the tag-specific fields.
+ snap = _snapshot()
+ parsed = json.loads(JSON.to_json(snap))
+ self.assertNotIn("tagCreateTime", parsed)
+ self.assertNotIn("tagTimeRetained", parsed)
+
+ def test_ttl_tag_matches_java_on_disk_shape(self):
+ tag = Tag.from_snapshot_and_tag_ttl(
+ _snapshot(),
+ timedelta(seconds=5),
+ datetime(1969, 1, 1, 0, 0, 0, 123456),
+ )
+ parsed = json.loads(JSON.to_json(tag))
+ self.assertEqual([1969, 1, 1, 0, 0, 0, 123456000],
+ parsed["tagCreateTime"])
+ self.assertEqual(5.0, parsed["tagTimeRetained"])
+
+ def test_round_trip(self):
+ tag = Tag.from_snapshot_and_tag_ttl(
+ _snapshot(), timedelta(days=1), datetime(2024, 6, 25, 10, 30, 45))
+ restored = JSON.from_json(JSON.to_json(tag), Tag)
+ self.assertEqual(datetime(2024, 6, 25, 10, 30, 45),
+ restored.tag_create_time)
+ self.assertEqual(timedelta(days=1), restored.tag_time_retained)
+
+ def test_reads_java_written_tag(self):
+ # A tag file produced by Java: array create-time + decimal seconds.
+ java_json = json.dumps({
+ "version": 3, "id": 0, "schemaId": 0,
+ "baseManifestList": None, "deltaManifestList": None,
+ "totalRecordCount": 0, "deltaRecordCount": 0,
+ "commitUser": None, "commitIdentifier": 0,
+ "commitKind": "APPEND", "timeMillis": 1000,
+ "tagCreateTime": [1969, 1, 1, 0, 0, 0, 123456789],
+ "tagTimeRetained": 5.000000000,
+ })
+ tag = JSON.from_json(java_json, Tag)
+ self.assertEqual(
+ datetime(1969, 1, 1, 0, 0, 0, 123456), tag.tag_create_time)
+ self.assertEqual(timedelta(seconds=5), tag.tag_time_retained)
+
+ def test_reads_legacy_plain_snapshot_tag(self):
+ # Old tag files (plain Snapshot JSON) load with None TTL fields.
+ tag = JSON.from_json(JSON.to_json(_snapshot()), Tag)
+ self.assertIsNone(tag.tag_create_time)
+ self.assertIsNone(tag.tag_time_retained)
+
+ def test_trim_to_snapshot_drops_tag_fields(self):
+ tag = Tag.from_snapshot_and_tag_ttl(
+ _snapshot(), timedelta(days=1), datetime(2024, 1, 1))
+ snap = tag.trim_to_snapshot()
+ self.assertNotIsInstance(snap, Tag)
+ self.assertFalse(hasattr(snap, "tag_create_time"))
+
+ def test_trim_to_snapshot_preserves_all_snapshot_fields(self):
+ # trim_to_snapshot must copy every Snapshot field (matching Java
+ # Tag.trimToSnapshot); a prior version silently dropped
+ # base/delta/changelog_manifest_list_size and properties, so
+ # FileSystemCatalog.get_tag() lost them for TTL tags.
+ original = _full_snapshot()
+ tag = Tag.from_snapshot_and_tag_ttl(
+ original, timedelta(days=1), datetime(2024, 1, 1))
+
+ snap = tag.trim_to_snapshot()
+
+ self.assertNotIsInstance(snap, Tag)
+ # Iterate over every declared Snapshot field so adding a field later
+ # without updating trim_to_snapshot fails this test.
+ for f in dataclasses.fields(Snapshot):
+ self.assertEqual(
+ getattr(original, f.name), getattr(snap, f.name),
+ f"trim_to_snapshot dropped/changed Snapshot field '{f.name}'")
+
+
+if __name__ == "__main__":
+ unittest.main()