Skip to content

Commit b10120a

Browse files
author
Alex Wang
committed
fix: fix datetime serilization for lambda invoker
- Fix datetime serilization for lambda invoker - Refactor serilization for filesystem and web module - Remove unused import - Remove unused txt file - More unit tests for web/server
1 parent 318bc19 commit b10120a

9 files changed

Lines changed: 305 additions & 63 deletions

File tree

src/aws_durable_execution_sdk_python_testing/invoker.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,13 @@
1212
DurableExecutionInvocationInputWithClient,
1313
DurableExecutionInvocationOutput,
1414
InitialExecutionState,
15-
InvocationStatus,
1615
)
1716

1817
from aws_durable_execution_sdk_python_testing.exceptions import (
1918
DurableFunctionsTestError,
20-
ServiceException,
2119
)
2220
from aws_durable_execution_sdk_python_testing.model import LambdaContext
21+
from aws_durable_execution_sdk_python_testing.serialization import SimthyDateTimeEncoder
2322

2423
if TYPE_CHECKING:
2524
from collections.abc import Callable
@@ -239,7 +238,7 @@ def invoke(
239238
response = client.invoke(
240239
FunctionName=function_name,
241240
InvocationType="RequestResponse", # Synchronous invocation
242-
Payload=json.dumps(input.to_dict(), default=str),
241+
Payload=json.dumps(input.to_dict(), cls=SimthyDateTimeEncoder),
243242
)
244243

245244
# Check HTTP status code

src/aws_durable_execution_sdk_python_testing/web/serialization.py renamed to src/aws_durable_execution_sdk_python_testing/serialization.py

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,11 @@
66

77
from __future__ import annotations
88

9-
import json
109
import os
10+
import json
1111
from typing import Any, Protocol
12-
from datetime import datetime
12+
from datetime import datetime, UTC
1313

14-
import aws_durable_execution_sdk_python
1514
import botocore.loaders # type: ignore
1615
from botocore.model import ServiceModel # type: ignore
1716
from botocore.parsers import create_parser # type: ignore
@@ -22,6 +21,40 @@
2221
)
2322

2423

24+
class DateTimeEncoder(json.JSONEncoder):
25+
"""Custom JSON encoder that handles datetime objects."""
26+
27+
def default(self, obj):
28+
if isinstance(obj, datetime):
29+
return obj.timestamp()
30+
return super().default(obj)
31+
32+
33+
class SimthyDateTimeEncoder(json.JSONEncoder):
34+
"""Custom JSON encoder that converts datetime objects to millisecond timestamps, which match smithy encoding behaviour"""
35+
36+
def default(self, obj):
37+
if isinstance(obj, datetime):
38+
# seconds_float to milliseconds
39+
return int(obj.timestamp() * 1000)
40+
return super().default(obj)
41+
42+
43+
def datetime_object_hook(obj):
44+
"""JSON object hook to convert unix timestamps back to datetime objects."""
45+
if isinstance(obj, dict):
46+
for key, value in obj.items():
47+
if isinstance(value, int | float) and key.endswith(
48+
("_timestamp", "_time", "Timestamp", "Time")
49+
):
50+
try: # noqa: SIM105
51+
obj[key] = datetime.fromtimestamp(value, tz=UTC)
52+
except (ValueError, OSError):
53+
# Leave as number if not a valid timestamp
54+
pass
55+
return obj
56+
57+
2558
class Serializer(Protocol):
2659
"""Interface for serializing data to bytes."""
2760

@@ -64,22 +97,13 @@ class JSONSerializer:
6497
def to_bytes(self, data: Any) -> bytes:
6598
"""Serialize data to JSON bytes."""
6699
try:
67-
json_string = json.dumps(
68-
data, separators=(",", ":"), default=self._default_handler
69-
)
100+
json_string = json.dumps(data, separators=(",", ":"), cls=DateTimeEncoder)
70101
return json_string.encode("utf-8")
71102
except (TypeError, ValueError) as e:
72103
raise InvalidParameterValueException(
73104
f"Failed to serialize data to JSON: {str(e)}"
74105
)
75106

76-
def _default_handler(self, obj: Any) -> float:
77-
"""Handle non-permitive objects."""
78-
if isinstance(obj, datetime):
79-
return obj.timestamp()
80-
# Raise TypeError for unsupported types
81-
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
82-
83107

84108
class AwsRestJsonSerializer:
85109
"""AWS rest-json serializer using boto."""

src/aws_durable_execution_sdk_python_testing/stores/filesystem.py

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,42 +4,21 @@
44

55
import json
66
import logging
7-
from datetime import UTC, datetime
87
from pathlib import Path
98

109
from aws_durable_execution_sdk_python_testing.exceptions import (
1110
ResourceNotFoundException,
1211
)
12+
from aws_durable_execution_sdk_python_testing.serialization import (
13+
DateTimeEncoder,
14+
datetime_object_hook,
15+
)
1316
from aws_durable_execution_sdk_python_testing.execution import Execution
1417
from aws_durable_execution_sdk_python_testing.stores.base import (
1518
BaseExecutionStore,
1619
)
1720

1821

19-
class DateTimeEncoder(json.JSONEncoder):
20-
"""Custom JSON encoder that handles datetime objects."""
21-
22-
def default(self, obj):
23-
if isinstance(obj, datetime):
24-
return obj.timestamp()
25-
return super().default(obj)
26-
27-
28-
def datetime_object_hook(obj):
29-
"""JSON object hook to convert unix timestamps back to datetime objects."""
30-
if isinstance(obj, dict):
31-
for key, value in obj.items():
32-
if isinstance(value, int | float) and key.endswith(
33-
("_timestamp", "_time", "Timestamp", "Time")
34-
):
35-
try: # noqa: SIM105
36-
obj[key] = datetime.fromtimestamp(value, tz=UTC)
37-
except (ValueError, OSError):
38-
# Leave as number if not a valid timestamp
39-
pass
40-
return obj
41-
42-
4322
class FileSystemExecutionStore(BaseExecutionStore):
4423
"""File system-based execution store for persistence."""
4524

src/aws_durable_execution_sdk_python_testing/stores/sqlite.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from aws_durable_execution_sdk_python_testing.stores.base import (
1818
ExecutionStore,
1919
)
20-
from aws_durable_execution_sdk_python_testing.stores.filesystem import (
20+
from aws_durable_execution_sdk_python_testing.serialization import (
2121
DateTimeEncoder,
2222
datetime_object_hook,
2323
)

src/aws_durable_execution_sdk_python_testing/web/models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
# Removed deprecated imports from web.errors
1616
from aws_durable_execution_sdk_python_testing.web.routes import Route
17-
from aws_durable_execution_sdk_python_testing.web.serialization import (
17+
from aws_durable_execution_sdk_python_testing.serialization import (
1818
AwsRestJsonDeserializer,
1919
JSONSerializer,
2020
Serializer,

tests/how-to-run-from-term.txt

Lines changed: 0 additions & 1 deletion
This file was deleted.

tests/invoker_test.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@
1212
InvocationStatus,
1313
)
1414

15+
from aws_durable_execution_sdk_python.lambda_service import (
16+
ExecutionDetails,
17+
Operation,
18+
OperationStatus,
19+
OperationType,
20+
)
21+
22+
from aws_durable_execution_sdk_python_testing.serialization import SimthyDateTimeEncoder
23+
24+
from datetime import datetime, UTC
25+
1526
from aws_durable_execution_sdk_python_testing.execution import Execution
1627
from aws_durable_execution_sdk_python_testing.invoker import (
1728
InProcessInvoker,
@@ -168,10 +179,23 @@ def test_lambda_invoker_invoke_success():
168179

169180
invoker = LambdaInvoker(lambda_client)
170181

182+
mock_operation = Operation(
183+
operation_id="op-1",
184+
parent_id=None,
185+
name="test-execution",
186+
start_timestamp=datetime.now(UTC),
187+
end_timestamp=datetime.now(UTC),
188+
operation_type=OperationType.EXECUTION,
189+
status=OperationStatus.SUCCEEDED,
190+
execution_details=ExecutionDetails(input_payload='{"test": "data"}'),
191+
)
192+
171193
input_data = DurableExecutionInvocationInput(
172194
durable_execution_arn="test-arn",
173195
checkpoint_token="test-token", # noqa: S106
174-
initial_execution_state=InitialExecutionState(operations=[], next_marker=""),
196+
initial_execution_state=InitialExecutionState(
197+
operations=[mock_operation], next_marker=""
198+
),
175199
)
176200

177201
response = invoker.invoke("test-function", input_data)
@@ -185,7 +209,7 @@ def test_lambda_invoker_invoke_success():
185209
lambda_client.invoke.assert_called_once_with(
186210
FunctionName="test-function",
187211
InvocationType="RequestResponse",
188-
Payload=json.dumps(input_data.to_dict(), default=str),
212+
Payload=json.dumps(input_data.to_dict(), cls=SimthyDateTimeEncoder),
189213
)
190214

191215

Lines changed: 118 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
from aws_durable_execution_sdk_python_testing.exceptions import (
1212
InvalidParameterValueException,
1313
)
14-
from aws_durable_execution_sdk_python_testing.web.serialization import (
14+
from aws_durable_execution_sdk_python_testing.serialization import (
1515
JSONSerializer,
1616
AwsRestJsonDeserializer,
1717
AwsRestJsonSerializer,
18+
SimthyDateTimeEncoder,
19+
datetime_object_hook,
1820
)
1921

2022

@@ -40,12 +42,10 @@ def test_aws_rest_json_serializer_should_initialize_and_serialize_data():
4042
)
4143

4244

43-
@patch("aws_durable_execution_sdk_python_testing.web.serialization.create_serializer")
44-
@patch("aws_durable_execution_sdk_python_testing.web.serialization.ServiceModel")
45-
@patch(
46-
"aws_durable_execution_sdk_python_testing.web.serialization.botocore.loaders.Loader"
47-
)
48-
@patch("aws_durable_execution_sdk_python_testing.web.serialization.os.path.dirname")
45+
@patch("aws_durable_execution_sdk_python_testing.serialization.create_serializer")
46+
@patch("aws_durable_execution_sdk_python_testing.serialization.ServiceModel")
47+
@patch("aws_durable_execution_sdk_python_testing.serialization.botocore.loaders.Loader")
48+
@patch("aws_durable_execution_sdk_python_testing.serialization.os.path.dirname")
4949
def test_aws_rest_json_serializer_should_create_serializer_with_boto_components(
5050
mock_dirname,
5151
mock_loader_class,
@@ -90,7 +90,7 @@ def test_aws_rest_json_serializer_should_create_serializer_with_boto_components(
9090
mock_service_model.operation_model.assert_called_once_with(operation_name)
9191

9292

93-
@patch("aws_durable_execution_sdk_python_testing.web.serialization.create_serializer")
93+
@patch("aws_durable_execution_sdk_python_testing.serialization.create_serializer")
9494
def test_aws_rest_json_serializer_should_raise_serialization_error_when_create_fails(
9595
mock_create_serializer,
9696
):
@@ -222,12 +222,10 @@ def test_aws_rest_json_deserializer_should_initialize_and_deserialize_data():
222222
mock_parser.parse.assert_called_once_with(expected_response_dict, mock_output_shape)
223223

224224

225-
@patch("aws_durable_execution_sdk_python_testing.web.serialization.create_parser")
226-
@patch("aws_durable_execution_sdk_python_testing.web.serialization.ServiceModel")
227-
@patch(
228-
"aws_durable_execution_sdk_python_testing.web.serialization.botocore.loaders.Loader"
229-
)
230-
@patch("aws_durable_execution_sdk_python_testing.web.serialization.os.path.dirname")
225+
@patch("aws_durable_execution_sdk_python_testing.serialization.create_parser")
226+
@patch("aws_durable_execution_sdk_python_testing.serialization.ServiceModel")
227+
@patch("aws_durable_execution_sdk_python_testing.serialization.botocore.loaders.Loader")
228+
@patch("aws_durable_execution_sdk_python_testing.serialization.os.path.dirname")
231229
def test_aws_rest_json_deserializer_should_create_deserializer_with_boto_components(
232230
mock_dirname,
233231
mock_loader_class,
@@ -274,7 +272,7 @@ def test_aws_rest_json_deserializer_should_create_deserializer_with_boto_compone
274272
mock_service_model.operation_model.assert_called_once_with(operation_name)
275273

276274

277-
@patch("aws_durable_execution_sdk_python_testing.web.serialization.create_parser")
275+
@patch("aws_durable_execution_sdk_python_testing.serialization.create_parser")
278276
def test_aws_rest_json_deserializer_should_raise_serialization_error_when_create_fails(
279277
mock_create_parser,
280278
):
@@ -574,3 +572,108 @@ def test_serialize_multiple_datetimes():
574572
expected = b'{"start":1735689600.0,"end":1767225599.0}'
575573

576574
assert result == expected
575+
576+
577+
def test_smithy_datetime_encoder_converts_datetime_to_milliseconds():
578+
"""Test that SimthyDateTimeEncoder converts datetime to millisecond timestamps."""
579+
encoder = SimthyDateTimeEncoder()
580+
dt = datetime(2025, 11, 5, 16, 30, 9, 895000, tzinfo=timezone.utc)
581+
582+
result = encoder.default(dt)
583+
expected = int(dt.timestamp() * 1000)
584+
585+
assert result == expected
586+
assert result == 1762360209895
587+
588+
589+
def test_smithy_datetime_encoder_handles_non_datetime_objects():
590+
"""Test that SimthyDateTimeEncoder delegates to parent for non-datetime objects."""
591+
encoder = SimthyDateTimeEncoder()
592+
593+
# Should delegate to parent's default method for non-datetime objects
594+
with pytest.raises(TypeError):
595+
encoder.default(object())
596+
597+
598+
def test_smithy_datetime_encoder_in_json_dumps():
599+
"""Test SimthyDateTimeEncoder when used with json.dumps."""
600+
dt = datetime(2025, 11, 5, 16, 30, 9, 895000, tzinfo=timezone.utc)
601+
data = {"timestamp": dt}
602+
603+
result = json.dumps(data, cls=SimthyDateTimeEncoder)
604+
expected = '{"timestamp": 1762360209895}'
605+
606+
assert result == expected
607+
608+
609+
def test_smithy_datetime_encoder_precision():
610+
"""Test that SimthyDateTimeEncoder maintains millisecond precision."""
611+
encoder = SimthyDateTimeEncoder()
612+
dt = datetime(2025, 11, 5, 16, 30, 9, 123456, tzinfo=timezone.utc)
613+
614+
result = encoder.default(dt)
615+
expected = int(dt.timestamp() * 1000)
616+
617+
assert result == expected
618+
assert result == 1762360209123
619+
620+
621+
def test_datetime_object_hook_converts_timestamp_fields():
622+
"""Test that datetime_object_hook converts timestamp fields to datetime objects."""
623+
from datetime import UTC
624+
625+
obj = {
626+
"user_timestamp": 1762360209,
627+
"created_time": 1735689600.0,
628+
"lastModifiedTimestamp": 1767225599,
629+
"endTime": 1762360209.895,
630+
}
631+
632+
result = datetime_object_hook(obj)
633+
634+
assert isinstance(result["user_timestamp"], datetime)
635+
assert isinstance(result["created_time"], datetime)
636+
assert isinstance(result["lastModifiedTimestamp"], datetime)
637+
assert isinstance(result["endTime"], datetime)
638+
assert result["user_timestamp"].tzinfo == UTC
639+
640+
641+
def test_datetime_object_hook_preserves_non_timestamp_fields():
642+
"""Test that datetime_object_hook preserves fields that don't match timestamp patterns."""
643+
obj = {"name": "test", "count": 42, "price": 19.99, "active": True}
644+
645+
result = datetime_object_hook(obj)
646+
647+
assert result == obj
648+
assert result["name"] == "test"
649+
assert result["count"] == 42
650+
assert result["price"] == 19.99
651+
assert result["active"] is True
652+
653+
654+
def test_datetime_object_hook_handles_non_dict_objects():
655+
"""Test that datetime_object_hook returns non-dict objects unchanged."""
656+
assert datetime_object_hook("string") == "string"
657+
assert datetime_object_hook(42) == 42
658+
assert datetime_object_hook([1, 2, 3]) == [1, 2, 3]
659+
assert datetime_object_hook(None) is None
660+
661+
662+
def test_datetime_object_hook_mixed_field_types():
663+
"""Test datetime_object_hook with mixed field types including nested structures."""
664+
obj = {
665+
"event_timestamp": 1762360209,
666+
"metadata": {"created_time": 1735689600, "description": "test event"},
667+
"tags": ["important", "user-action"],
668+
"count": 1,
669+
}
670+
671+
result = datetime_object_hook(obj)
672+
673+
assert isinstance(result["event_timestamp"], datetime)
674+
assert result["metadata"] == {
675+
"created_time": 1735689600,
676+
"description": "test event",
677+
}
678+
assert result["tags"] == ["important", "user-action"]
679+
assert result["count"] == 1

0 commit comments

Comments
 (0)