Skip to content

Commit e30e751

Browse files
clauderelay-bot
authored andcommitted
fix: normalize timestamps to UTC for consistent comparisons
Address timezone correctness issues flagged in code review: - Parse FTP MDTM timestamps as UTC-aware (RFC 3659 defines MDTM as UTC) in both get_mtime_via_conn() and get_remote_mtime() - Use datetime.fromtimestamp(st_mtime, tz=timezone.utc) for local file mtime so both sides of comparisons are UTC-aware - Normalize _get_meta_timestamp() to always return UTC-aware datetimes, handling both aware and naive ISO timestamps from frames-meta.json - Gate recheck_existing size comparison on remote_mtime is None so it only fires as a fallback when MDTM is unavailable, preventing downloads of older files that happen to differ in size https://claude.ai/code/session_0197c48GcqyW92ZGQ3Gm1WJw Signed-off-by: Claude <noreply@anthropic.com>
1 parent 1dccd25 commit e30e751

File tree

2 files changed

+46
-20
lines changed

2 files changed

+46
-20
lines changed

src/zyra/connectors/backends/ftp.py

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import warnings
1919
from collections.abc import Iterable
2020
from dataclasses import dataclass
21-
from datetime import datetime
21+
from datetime import datetime, timezone
2222
from ftplib import FTP, all_errors
2323
from io import BytesIO
2424
from pathlib import Path
@@ -429,13 +429,18 @@ def get_size_via_conn(filename: str) -> int | None:
429429
return None
430430

431431
def get_mtime_via_conn(filename: str) -> datetime | None:
432-
"""Get file mtime using the shared connection via MDTM."""
432+
"""Get file mtime using the shared connection via MDTM.
433+
434+
Returns a UTC-aware datetime (MDTM timestamps are UTC per RFC 3659).
435+
"""
433436
try:
434437
conn = ensure_ftp_connection()
435438
resp = conn.sendcmd(f"MDTM {filename}")
436439
if resp.startswith("213 "):
437440
ts_str = resp[4:].strip()
438-
return datetime.strptime(ts_str, "%Y%m%d%H%M%S")
441+
return datetime.strptime(ts_str, "%Y%m%d%H%M%S").replace(
442+
tzinfo=timezone.utc
443+
)
439444
except all_errors as exc:
440445
logging.debug("FTP MDTM failed for %s: %s", filename, exc)
441446
return None
@@ -521,7 +526,8 @@ def get_remote_mtime(
521526
) -> datetime | None:
522527
"""Return modification time from FTP MDTM command, or None if unavailable.
523528
524-
The MDTM command returns timestamps in the format ``YYYYMMDDhhmmss``.
529+
The MDTM command returns timestamps in UTC (RFC 3659) in the format
530+
``YYYYMMDDhhmmss``. Returns a UTC-aware datetime.
525531
Not all FTP servers support this command; failures return None gracefully.
526532
"""
527533
v = _maybe_delegate(
@@ -543,11 +549,13 @@ def get_remote_mtime(
543549
directory, filename = remote_path.rsplit("/", 1)
544550
if directory:
545551
ftp.cwd(directory)
546-
# FTP MDTM returns: "213 YYYYMMDDhhmmss"
552+
# FTP MDTM returns: "213 YYYYMMDDhhmmss" (UTC per RFC 3659)
547553
resp = ftp.sendcmd(f"MDTM {filename}")
548554
if resp.startswith("213 "):
549555
ts_str = resp[4:].strip()
550-
return datetime.strptime(ts_str, "%Y%m%d%H%M%S")
556+
return datetime.strptime(ts_str, "%Y%m%d%H%M%S").replace(
557+
tzinfo=timezone.utc
558+
)
551559
return None
552560
except all_errors:
553561
return None
@@ -620,7 +628,11 @@ def _is_missing_companion_meta(filename: str, frames_meta: dict | None) -> bool:
620628

621629

622630
def _get_meta_timestamp(filename: str, frames_meta: dict | None) -> datetime | None:
623-
"""Extract timestamp for a file from frames-meta.json."""
631+
"""Extract timestamp for a file from frames-meta.json.
632+
633+
Always returns a UTC-aware datetime so comparisons with other
634+
UTC-aware timestamps are safe.
635+
"""
624636
if not frames_meta:
625637
return None
626638
frames = frames_meta.get("frames", [])
@@ -631,7 +643,13 @@ def _get_meta_timestamp(filename: str, frames_meta: dict | None) -> datetime | N
631643
ts = frame.get("timestamp")
632644
if ts:
633645
try:
634-
return datetime.fromisoformat(ts)
646+
dt = datetime.fromisoformat(ts)
647+
# Normalize to UTC-aware
648+
if dt.tzinfo is None:
649+
dt = dt.replace(tzinfo=timezone.utc)
650+
else:
651+
dt = dt.astimezone(timezone.utc)
652+
return dt
635653
except ValueError:
636654
pass
637655
break
@@ -687,7 +705,9 @@ def should_download(
687705
meta_ts = _get_meta_timestamp(remote_name, frames_meta)
688706
if meta_ts:
689707
try:
690-
local_mtime = datetime.fromtimestamp(local_path.stat().st_mtime)
708+
local_mtime = datetime.fromtimestamp(
709+
local_path.stat().st_mtime, tz=timezone.utc
710+
)
691711
if meta_ts > local_mtime:
692712
return (True, "meta timestamp newer than local")
693713
except OSError as exc:
@@ -709,18 +729,21 @@ def should_download(
709729
if threshold is not None and remote_size >= threshold:
710730
return (True, f"remote size {remote_size} >= threshold {threshold}")
711731

712-
# 9. Recheck existing (size comparison when mtime unavailable)
732+
# 9. Recheck existing (size fallback when mtime unavailable)
713733
if (
714734
options.recheck_existing
735+
and remote_mtime is None
715736
and remote_size is not None
716737
and remote_size != local_size
717738
):
718739
return (True, f"size mismatch: local={local_size}, remote={remote_size}")
719740

720-
# 10. Default: MDTM-based comparison
741+
# 10. Default: MDTM-based comparison (both sides UTC-aware)
721742
if remote_mtime is not None:
722743
try:
723-
local_mtime = datetime.fromtimestamp(local_path.stat().st_mtime)
744+
local_mtime = datetime.fromtimestamp(
745+
local_path.stat().st_mtime, tz=timezone.utc
746+
)
724747
if remote_mtime > local_mtime:
725748
return (True, "remote mtime newer")
726749
except OSError as exc:

tests/connectors/test_ftp_backend.py

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ def test_should_download_min_remote_size_not_met(self, tmp_path):
293293
def test_should_download_mtime_newer_remote(self, tmp_path):
294294
"""Default behavior downloads when remote mtime is newer."""
295295
import time
296-
from datetime import datetime
296+
from datetime import datetime, timezone
297297

298298
local_path = tmp_path / "file.png"
299299
local_path.write_bytes(b"content")
@@ -304,7 +304,7 @@ def test_should_download_mtime_newer_remote(self, tmp_path):
304304
os.utime(local_path, (old_time, old_time))
305305

306306
options = ftp_backend.SyncOptions()
307-
remote_mtime = datetime.now() # Current time (newer)
307+
remote_mtime = datetime.now(tz=timezone.utc) # Current time (newer)
308308
result, reason = ftp_backend.should_download(
309309
"file.png", local_path, 100, remote_mtime, options
310310
)
@@ -313,14 +313,14 @@ def test_should_download_mtime_newer_remote(self, tmp_path):
313313

314314
def test_should_download_mtime_older_remote(self, tmp_path):
315315
"""Default behavior skips when remote mtime is older."""
316-
from datetime import datetime, timedelta
316+
from datetime import datetime, timedelta, timezone
317317

318318
local_path = tmp_path / "file.png"
319319
local_path.write_bytes(b"content")
320320
# Note: local file has current mtime from write_bytes
321321

322322
options = ftp_backend.SyncOptions()
323-
remote_mtime = datetime.now() - timedelta(days=7) # 7 days ago
323+
remote_mtime = datetime.now(tz=timezone.utc) - timedelta(days=7) # 7 days ago
324324
result, reason = ftp_backend.should_download(
325325
"file.png", local_path, 100, remote_mtime, options
326326
)
@@ -330,7 +330,7 @@ def test_should_download_mtime_older_remote(self, tmp_path):
330330
def test_should_download_prefer_remote_if_meta_newer(self, tmp_path):
331331
"""--prefer-remote-if-meta-newer uses frames-meta.json timestamps."""
332332
import time
333-
from datetime import datetime
333+
from datetime import datetime, timezone
334334

335335
local_path = tmp_path / "frame_001.png"
336336
local_path.write_bytes(b"content")
@@ -343,7 +343,10 @@ def test_should_download_prefer_remote_if_meta_newer(self, tmp_path):
343343
options = ftp_backend.SyncOptions(prefer_remote_if_meta_newer=True)
344344
frames_meta = {
345345
"frames": [
346-
{"filename": "frame_001.png", "timestamp": datetime.now().isoformat()}
346+
{
347+
"filename": "frame_001.png",
348+
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
349+
}
347350
]
348351
}
349352
result, reason = ftp_backend.should_download(
@@ -399,9 +402,9 @@ def quit(self):
399402

400403
with patch("zyra.connectors.backends.ftp.FTP", _FTPMdtm):
401404
result = ftp_backend.get_remote_mtime("ftp://host/path/file.txt")
402-
from datetime import datetime
405+
from datetime import datetime, timezone
403406

404-
assert result == datetime(2024, 6, 15, 12, 0, 0)
407+
assert result == datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)
405408

406409
def test_get_remote_mtime_not_supported(self, monkeypatch):
407410
"""MDTM not supported returns None gracefully."""

0 commit comments

Comments
 (0)