From 237662937ddff2e6e4e99ee3107e393e7d9b6af8 Mon Sep 17 00:00:00 2001 From: Rahul-cs50 Date: Sat, 31 Oct 2026 14:14:31 +0530 Subject: [PATCH] Read a naive datetime in older_than as local time expire_snapshots().older_than() converted its cutoff with datetime_to_millis, which reads a naive datetime as UTC. The docs call it with datetime.now() - timedelta(...), a naive local time, so east of UTC the cutoff landed later and expired snapshots that should be kept, and west of UTC it landed earlier. A naive datetime is now read as local time, as datetime.timestamp() does; an aware one is unchanged. Closes #4182 --- pyiceberg/table/update/snapshot.py | 3 +++ tests/table/test_expire_snapshots.py | 29 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/pyiceberg/table/update/snapshot.py b/pyiceberg/table/update/snapshot.py index 8feebb9901..d0a5ed476d 100644 --- a/pyiceberg/table/update/snapshot.py +++ b/pyiceberg/table/update/snapshot.py @@ -1418,11 +1418,14 @@ def older_than(self, dt: datetime) -> ExpireSnapshots: Args: dt (datetime): Only snapshots with datetime < this value will be expired. + A naive datetime is read as local time, like datetime.now() returns it. Returns: This for method chaining. """ protected_ids = self._get_protected_snapshot_ids() + if dt.tzinfo is None: + dt = dt.astimezone() expire_from = datetime_to_millis(dt) for snapshot in self._transaction.table_metadata.snapshots: if snapshot.timestamp_ms < expire_from and snapshot.snapshot_id not in protected_ids: diff --git a/tests/table/test_expire_snapshots.py b/tests/table/test_expire_snapshots.py index 106e5b786c..a393b84b46 100644 --- a/tests/table/test_expire_snapshots.py +++ b/tests/table/test_expire_snapshots.py @@ -15,6 +15,7 @@ # specific language governing permissions and limitations # under the License. import threading +import time from datetime import datetime, timedelta from unittest.mock import MagicMock, Mock from uuid import uuid4 @@ -174,6 +175,34 @@ def test_expire_snapshots_by_timestamp_skips_protected(table_v2: Table) -> None: assert remove_update.snapshot_ids == [] +def test_expire_snapshots_older_than_reads_naive_datetime_as_local_time(table_v2: Table, monkeypatch: pytest.MonkeyPatch) -> None: + """Test that older_than reads a naive datetime, as datetime.now() returns it, in the local time zone.""" + if not hasattr(time, "tzset"): + pytest.skip("time.tzset is not available on Windows") + EXPIRE_SNAPSHOT = 3051729675574597004 + KEEP_SNAPSHOT = 3055729675574597004 + expire_snapshot_ms = 1515100955770 + + table_v2.metadata = table_v2.metadata.model_copy( + update={"refs": {"main": MagicMock(snapshot_id=KEEP_SNAPSHOT, snapshot_ref_type="branch")}} + ) + + # A zone east of UTC, so reading local time as UTC moves the cutoff nine hours earlier + monkeypatch.setenv("TZ", "Asia/Tokyo") + time.tzset() + try: + local_snapshot_time = datetime.fromtimestamp(expire_snapshot_ms / 1000) + for cutoff, expected in ( + (local_snapshot_time - timedelta(minutes=1), set()), + (local_snapshot_time + timedelta(minutes=1), {EXPIRE_SNAPSHOT}), + ): + expire = table_v2.maintenance.expire_snapshots().older_than(cutoff) + assert expire._snapshot_ids_to_expire == expected + finally: + monkeypatch.undo() + time.tzset() + + def test_expire_snapshots_by_ids(table_v2: Table) -> None: """Test that multiple unprotected snapshots can be expired by IDs.""" EXPIRE_SNAPSHOT_1 = 3051729675574597004