Skip to content

Commit e161557

Browse files
authored
fix(sync): Handle timezone-naive datetimes in sync.toml (#8477) (#8487)
* fix(sync): Handle timezone-naive datetimes in sync.toml (#8477) * address comment: add "Z" handler * Address PR review comments - Add error handling for invalid datetime strings in _parse_datetime_from_toml - Add test case for invalid datetime format - Remove PR/issue references from test class docstrings * fix(sync): migrate to using epoch time for sync timestamp * fix make pr * Update samcli/commands/sync/sync_context.py
1 parent 98f45ba commit e161557

2 files changed

Lines changed: 255 additions & 10 deletions

File tree

samcli/commands/sync/sync_context.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from dataclasses import dataclass
88
from datetime import datetime, timezone
99
from pathlib import Path
10-
from typing import Dict, Optional, cast
10+
from typing import Dict, Optional, Union, cast
1111

1212
import tomlkit
1313
from tomlkit.items import Item
@@ -65,6 +65,45 @@ def update_infra_sync_time(self) -> None:
6565
self.latest_infra_sync_time = datetime.now(timezone.utc)
6666

6767

68+
def _parse_time_from_toml(time_value: Union[str, int, float, datetime]) -> Optional[datetime]:
69+
"""
70+
Parse time from TOML file - supports both epoch and ISO format.
71+
72+
Handles legacy ISO format strings as sam migrates to using epoch timestamps for backward compatibility.
73+
New writes always use epoch format.
74+
75+
Parameters
76+
----------
77+
time_value: Union[str, int, float, datetime]
78+
Either epoch timestamp (new format), ISO format string (legacy), or datetime object
79+
80+
Returns
81+
-------
82+
Optional[datetime]
83+
Timezone-aware datetime in UTC, or None if parsing fails
84+
"""
85+
try:
86+
if isinstance(time_value, datetime):
87+
return time_value if time_value.tzinfo else time_value.replace(tzinfo=timezone.utc)
88+
89+
if isinstance(time_value, (int, float)):
90+
return datetime.fromtimestamp(time_value, tz=timezone.utc)
91+
92+
if isinstance(time_value, str):
93+
if time_value.endswith("Z"):
94+
time_value = time_value[:-1] + "+00:00"
95+
parsed = datetime.fromisoformat(time_value)
96+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
97+
98+
except (ValueError, OSError) as e:
99+
LOG.warning(
100+
"Failed to parse timestamp from sync.toml: %s. Triggering full CloudFormation deployment. Error: %s",
101+
time_value,
102+
e,
103+
)
104+
return None
105+
106+
68107
def _sync_state_to_toml_document(sync_state: SyncState) -> TOMLDocument:
69108
"""
70109
Writes the sync state information to the TOML file.
@@ -82,7 +121,7 @@ def _sync_state_to_toml_document(sync_state: SyncState) -> TOMLDocument:
82121
sync_state_toml_table = tomlkit.table()
83122
sync_state_toml_table[DEPENDENCY_LAYER] = sync_state.dependency_layer
84123
if sync_state.latest_infra_sync_time:
85-
sync_state_toml_table[LATEST_INFRA_SYNC_TIME] = sync_state.latest_infra_sync_time.isoformat()
124+
sync_state_toml_table[LATEST_INFRA_SYNC_TIME] = sync_state.latest_infra_sync_time.timestamp()
86125

87126
resource_sync_states_toml_table = tomlkit.table()
88127
for resource_id in sync_state.resource_sync_states:
@@ -91,7 +130,7 @@ def _sync_state_to_toml_document(sync_state: SyncState) -> TOMLDocument:
91130
resource_sync_state_toml_table = tomlkit.table()
92131

93132
resource_sync_state_toml_table[HASH] = resource_sync_state.hash_value
94-
resource_sync_state_toml_table[SYNC_TIME] = resource_sync_state.sync_time.isoformat()
133+
resource_sync_state_toml_table[SYNC_TIME] = resource_sync_state.sync_time.timestamp()
95134

96135
# For Nested stack resources, replace "/" with "-"
97136
resource_id_toml = resource_id.replace("/", "-")
@@ -129,9 +168,15 @@ def _toml_document_to_sync_state(toml_document: Dict) -> Optional[SyncState]:
129168
if resource_sync_states_toml_table:
130169
for resource_id in resource_sync_states_toml_table:
131170
resource_sync_state_toml_table = resource_sync_states_toml_table.get(resource_id)
171+
sync_time_str = resource_sync_state_toml_table.get(SYNC_TIME)
172+
# Parse datetime and ensure it's timezone-aware UTC (consistent with how we write)
173+
sync_time = _parse_time_from_toml(sync_time_str)
174+
if sync_time is None:
175+
# Skip this resource if timestamp is invalid - resource will be re-synced on next sam sync
176+
continue
132177
resource_sync_state = ResourceSyncState(
133178
resource_sync_state_toml_table.get(HASH),
134-
datetime.fromisoformat(resource_sync_state_toml_table.get(SYNC_TIME)),
179+
sync_time,
135180
)
136181

137182
# For Nested stack resources, replace "-" with "/"
@@ -142,9 +187,9 @@ def _toml_document_to_sync_state(toml_document: Dict) -> Optional[SyncState]:
142187
latest_infra_sync_time = None
143188
if sync_state_toml_table:
144189
dependency_layer = sync_state_toml_table.get(DEPENDENCY_LAYER)
145-
latest_infra_sync_time = sync_state_toml_table.get(LATEST_INFRA_SYNC_TIME)
146-
if latest_infra_sync_time:
147-
latest_infra_sync_time = datetime.fromisoformat(str(latest_infra_sync_time))
190+
latest_infra_sync_time_str = sync_state_toml_table.get(LATEST_INFRA_SYNC_TIME)
191+
if latest_infra_sync_time_str:
192+
latest_infra_sync_time = _parse_time_from_toml(latest_infra_sync_time_str)
148193
sync_state = SyncState(dependency_layer, resource_sync_states, latest_infra_sync_time)
149194

150195
return sync_state

tests/unit/commands/sync/test_sync_context.py

Lines changed: 203 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
)
2323
from samcli.lib.build.build_graph import DEFAULT_DEPENDENCIES_DIR
2424

25-
MOCK_RESOURCE_SYNC_TIME = datetime(2023, 2, 8, 12, 12, 12)
25+
MOCK_RESOURCE_SYNC_TIME = datetime(2023, 2, 8, 12, 12, 12, tzinfo=timezone.utc)
2626
MOCK_INFRA_SYNC_TIME = datetime.now(timezone.utc)
2727

2828

@@ -141,7 +141,7 @@ def test_sync_state_to_toml(self, dependency_layer, latest_infra_sync_time, reso
141141
self.assertEqual(dependency_layer_toml_field, dependency_layer)
142142

143143
latest_infra_sync_time_toml_field = sync_state_toml_table.get(LATEST_INFRA_SYNC_TIME)
144-
self.assertEqual(latest_infra_sync_time_toml_field, latest_infra_sync_time.isoformat())
144+
self.assertEqual(latest_infra_sync_time_toml_field, latest_infra_sync_time.timestamp())
145145

146146
resource_sync_states_toml_field = toml_document.get(RESOURCE_SYNC_STATES)
147147
self.assertIsNotNone(resource_sync_states_toml_field)
@@ -155,7 +155,7 @@ def test_sync_state_to_toml(self, dependency_layer, latest_infra_sync_time, reso
155155
resource_sync_state_toml_table.get(HASH),
156156
)
157157
self.assertEqual(
158-
resource_sync_states[resource_sync_state_resource_id].sync_time.isoformat(),
158+
resource_sync_states[resource_sync_state_resource_id].sync_time.timestamp(),
159159
resource_sync_state_toml_table.get(SYNC_TIME),
160160
)
161161

@@ -288,3 +288,203 @@ def test_sync_context_has_no_previous_state_if_file_doesnt_exist(self, patched_r
288288
self.assertIsNone(self.sync_context._previous_state)
289289
self.assertIsNotNone(self.sync_context._current_state)
290290
patched_rmtree_if_exists.assert_not_called()
291+
292+
293+
class TestTimestampParsing(TestCase):
294+
"""Tests for timestamp parsing with various formats and error handling"""
295+
296+
@parameterized.expand(
297+
[
298+
# Valid timestamp formats
299+
(
300+
"timezone_naive",
301+
"2025-12-03T22:10:11.916279",
302+
{"Resource1": ("hash1", "2025-12-03T22:10:35.345701")},
303+
True,
304+
["Resource1"],
305+
[],
306+
),
307+
(
308+
"timezone_aware",
309+
"2025-12-03T22:10:11.916279+00:00",
310+
{"Resource1": ("hash1", "2025-12-03T22:10:35.345701+00:00")},
311+
True,
312+
["Resource1"],
313+
[],
314+
),
315+
(
316+
"z_suffix",
317+
"2024-05-08T15:16:43Z",
318+
{"Resource1": ("hash1", "2024-05-08T15:16:43Z")},
319+
True,
320+
["Resource1"],
321+
[],
322+
),
323+
("epoch_format", 1733267411.916279, {"Resource1": ("hash1", 1733267435.345701)}, True, ["Resource1"], []),
324+
(
325+
"mixed_formats",
326+
"2024-05-08T15:16:43Z",
327+
{"Resource1": ("hash1", "2025-12-03T22:10:35+00:00"), "Resource2": ("hash2", 1733267440.123456)},
328+
True,
329+
["Resource1", "Resource2"],
330+
[],
331+
),
332+
(
333+
"nested_resource",
334+
"2024-05-08T15:16:43Z",
335+
{"Parent/Child/Resource": ("hash", "2024-05-08T15:16:43Z")},
336+
True,
337+
["Parent/Child/Resource"],
338+
[],
339+
),
340+
# Invalid timestamp formats
341+
(
342+
"invalid_resource",
343+
1733267411.916279,
344+
{"Valid": ("hash1", 1733267435.345701), "Invalid": ("hash2", "bad-timestamp")},
345+
True,
346+
["Valid"],
347+
["Invalid"],
348+
),
349+
("invalid_infra", "not-timestamp", {"Valid": ("hash1", 1733267435.345701)}, False, ["Valid"], []),
350+
(
351+
"multiple_invalid",
352+
1733267411.916279,
353+
{
354+
"Valid1": ("h1", 1733267435.345701),
355+
"Invalid1": ("h2", "bad1"),
356+
"Valid2": ("h3", 1733267440.123456),
357+
"Invalid2": ("h4", "bad2"),
358+
},
359+
True,
360+
["Valid1", "Valid2"],
361+
["Invalid1", "Invalid2"],
362+
),
363+
("all_invalid", "bad-infra", {"Invalid": ("hash", "bad-resource")}, False, [], ["Invalid"]),
364+
]
365+
)
366+
def test_timestamp_parsing(
367+
self, test_name, infra_sync_time, resources, has_valid_infra, expected_resources, missing_resources
368+
):
369+
"""Test timestamp parsing for various formats and error handling"""
370+
# Build TOML string
371+
if isinstance(infra_sync_time, str):
372+
infra_value = f'"{infra_sync_time}"'
373+
else:
374+
infra_value = str(infra_sync_time)
375+
376+
toml_str = f"""
377+
[sync_state]
378+
dependency_layer = true
379+
latest_infra_sync_time = {infra_value}
380+
381+
[resource_sync_states]
382+
"""
383+
for resource_id, (resource_hash, resource_sync_time) in resources.items():
384+
if isinstance(resource_sync_time, str):
385+
sync_time_value = f'"{resource_sync_time}"'
386+
else:
387+
sync_time_value = str(resource_sync_time)
388+
389+
resource_id_toml = resource_id.replace("/", "-")
390+
toml_str += f"""
391+
[resource_sync_states.{resource_id_toml}]
392+
hash = "{resource_hash}"
393+
sync_time = {sync_time_value}
394+
"""
395+
396+
toml_doc = tomlkit.loads(toml_str)
397+
398+
# Mock logger if we expect invalid timestamps
399+
if missing_resources or not has_valid_infra:
400+
with patch("samcli.commands.sync.sync_context.LOG") as mock_log:
401+
sync_state = _toml_document_to_sync_state(toml_doc)
402+
mock_log.warning.assert_called()
403+
else:
404+
sync_state = _toml_document_to_sync_state(toml_doc)
405+
406+
# Verify infra sync time
407+
if has_valid_infra:
408+
self.assertIsNotNone(sync_state.latest_infra_sync_time)
409+
self.assertEqual(sync_state.latest_infra_sync_time.tzinfo, timezone.utc)
410+
# Verify datetime comparison works
411+
time_diff = datetime.now(timezone.utc) - sync_state.latest_infra_sync_time
412+
self.assertIsNotNone(time_diff)
413+
else:
414+
self.assertIsNone(sync_state.latest_infra_sync_time)
415+
416+
# Verify expected resources were loaded with correct timezone
417+
for resource_id in expected_resources:
418+
self.assertIn(resource_id, sync_state.resource_sync_states)
419+
self.assertEqual(sync_state.resource_sync_states[resource_id].sync_time.tzinfo, timezone.utc)
420+
421+
# Verify missing resources were skipped
422+
for resource_id in missing_resources:
423+
self.assertNotIn(resource_id, sync_state.resource_sync_states)
424+
425+
426+
class TestEpochTimestampHandling(TestCase):
427+
"""Tests for epoch timestamp format (new format with backward compatibility)"""
428+
429+
def test_epoch_format_read(self):
430+
"""Test reading epoch timestamps (new format)"""
431+
toml_str = """
432+
[sync_state]
433+
dependency_layer = true
434+
latest_infra_sync_time = 1733267411.916279
435+
436+
[resource_sync_states]
437+
438+
[resource_sync_states.MockResourceId]
439+
hash = "mock-hash"
440+
sync_time = 1733267435.345701
441+
"""
442+
toml_doc = tomlkit.loads(toml_str)
443+
sync_state = _toml_document_to_sync_state(toml_doc)
444+
445+
# Verify both are timezone-aware UTC
446+
self.assertEqual(sync_state.latest_infra_sync_time.tzinfo, timezone.utc)
447+
self.assertEqual(sync_state.resource_sync_states["MockResourceId"].sync_time.tzinfo, timezone.utc)
448+
449+
def test_epoch_format_write(self):
450+
"""Test that writing always produces epoch, not ISO strings"""
451+
sync_state = SyncState(
452+
dependency_layer=True,
453+
resource_sync_states={"TestResource": ResourceSyncState("hash123", datetime.now(timezone.utc))},
454+
latest_infra_sync_time=datetime.now(timezone.utc),
455+
)
456+
457+
toml_doc = _sync_state_to_toml_document(sync_state)
458+
459+
# Verify written values are numeric (epoch), not strings (ISO)
460+
self.assertIsInstance(toml_doc["sync_state"]["latest_infra_sync_time"], (int, float))
461+
self.assertIsInstance(toml_doc["resource_sync_states"]["TestResource"]["sync_time"], (int, float))
462+
463+
def test_backward_compatibility_mixed_formats(self):
464+
"""Test that old ISO and new epoch can coexist during migration"""
465+
toml_str = """
466+
[sync_state]
467+
dependency_layer = true
468+
latest_infra_sync_time = 1733267411.916279
469+
470+
[resource_sync_states]
471+
472+
[resource_sync_states.OldResource]
473+
hash = "old-hash"
474+
sync_time = "2025-12-03T22:10:35.345701"
475+
476+
[resource_sync_states.NewResource]
477+
hash = "new-hash"
478+
sync_time = 1733267435.345701
479+
"""
480+
toml_doc = tomlkit.loads(toml_str)
481+
sync_state = _toml_document_to_sync_state(toml_doc)
482+
483+
# Both should work and be timezone-aware
484+
self.assertEqual(sync_state.resource_sync_states["OldResource"].sync_time.tzinfo, timezone.utc)
485+
self.assertEqual(sync_state.resource_sync_states["NewResource"].sync_time.tzinfo, timezone.utc)
486+
487+
# Verify datetime comparison works
488+
current_time = datetime.now(timezone.utc)
489+
time_diff = current_time - sync_state.latest_infra_sync_time
490+
self.assertIsNotNone(time_diff)

0 commit comments

Comments
 (0)