Skip to content

Commit 20bbd9b

Browse files
committed
1 parent 9c8ebe1 commit 20bbd9b

4 files changed

Lines changed: 201 additions & 9 deletions

File tree

awscli/botocore/args.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,8 +146,23 @@ def get_client_args(
146146
proxies_config=new_config.proxies_config,
147147
)
148148

149+
150+
# Emit event to allow service-specific or customer customization of serializer kwargs
151+
event_name = f'creating-serializer.{service_name}'
152+
serializer_kwargs = {
153+
'timestamp_precision': botocore.serialize.TIMESTAMP_PRECISION_DEFAULT
154+
}
155+
event_emitter.emit(
156+
event_name,
157+
protocol_name=protocol,
158+
service_model=service_model,
159+
serializer_kwargs=serializer_kwargs,
160+
)
161+
149162
serializer = botocore.serialize.create_serializer(
150-
protocol, parameter_validation
163+
protocol,
164+
parameter_validation,
165+
timestamp_precision=serializer_kwargs['timestamp_precision'],
151166
)
152167
response_parser = botocore.parsers.create_parser(protocol)
153168

awscli/botocore/handlers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
UnsupportedTLSVersionWarning,
5454
)
5555
from botocore.regions import EndpointResolverBuiltins
56+
from botocore.serialize import TIMESTAMP_PRECISION_MILLISECOND
5657
from botocore.signers import (
5758
add_dsql_generate_db_auth_token_methods,
5859
add_generate_db_auth_token,
@@ -995,6 +996,10 @@ def remove_bedrock_runtime_invoke_model_with_bidirectional_stream(
995996
if 'invoke_model_with_bidirectional_stream' in class_attributes:
996997
del class_attributes['invoke_model_with_bidirectional_stream']
997998

999+
def enable_millisecond_timestamp_precision(serializer_kwargs, **kwargs):
1000+
"""Event handler to enable millisecond precision"""
1001+
serializer_kwargs['timestamp_precision'] = TIMESTAMP_PRECISION_MILLISECOND
1002+
9981003

9991004
def remove_bucket_from_url_paths_from_model(params, model, context, **kwargs):
10001005
"""Strips leading `{Bucket}/` from any operations that have it.
@@ -1345,6 +1350,10 @@ def _set_extra_headers_for_unsigned_request(
13451350
'creating-client-class.bedrock-runtime',
13461351
remove_bedrock_runtime_invoke_model_with_bidirectional_stream,
13471352
),
1353+
(
1354+
'creating-serializer.bedrock-agentcore',
1355+
enable_millisecond_timestamp_precision,
1356+
),
13481357
('after-call.iam', json_decode_policies),
13491358
('after-call.ec2.GetConsoleOutput', decode_console_output),
13501359
('after-call.cloudformation.GetTemplate', json_decode_template_body),

awscli/botocore/serialize.py

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,32 @@
6363
# Same as ISO8601, but with microsecond precision.
6464
ISO8601_MICRO = '%Y-%m-%dT%H:%M:%S.%fZ'
6565

66+
TIMESTAMP_PRECISION_DEFAULT = 'default'
67+
TIMESTAMP_PRECISION_MILLISECOND = 'millisecond'
68+
TIMESTAMP_PRECISION_OPTIONS = (
69+
TIMESTAMP_PRECISION_DEFAULT,
70+
TIMESTAMP_PRECISION_MILLISECOND,
71+
)
6672

67-
def create_serializer(protocol_name, include_validation=True):
68-
# TODO: Unknown protocols.
69-
serializer = SERIALIZERS[protocol_name]()
73+
def create_serializer(
74+
protocol_name,
75+
include_validation=True,
76+
timestamp_precision=TIMESTAMP_PRECISION_DEFAULT,
77+
):
78+
"""Create a serializer for the given protocol.
79+
:param protocol_name: The protocol name to create a serializer for.
80+
:type protocol_name: str
81+
:param include_validation: Whether to include parameter validation.
82+
:type include_validation: bool
83+
:param timestamp_precision: Timestamp precision level.
84+
- 'default': Microseconds for ISO timestamps, seconds for Unix and RFC
85+
- 'millisecond': Millisecond precision (ISO/Unix), seconds for RFC
86+
:type timestamp_precision: str
87+
:return: A serializer instance for the given protocol.
88+
"""
89+
serializer = SERIALIZERS[protocol_name](
90+
timestamp_precision=timestamp_precision
91+
)
7092
if include_validation:
7193
validator = validate.ParamValidator()
7294
serializer = validate.ParamValidationDecorator(validator, serializer)
@@ -82,6 +104,14 @@ class Serializer:
82104
MAP_TYPE = dict
83105
DEFAULT_ENCODING = 'utf-8'
84106

107+
def __init__(self, timestamp_precision=TIMESTAMP_PRECISION_DEFAULT):
108+
if timestamp_precision not in TIMESTAMP_PRECISION_OPTIONS:
109+
raise ValueError(
110+
f"Invalid timestamp precision found while creating serializer: {timestamp_precision}"
111+
)
112+
113+
self._timestamp_precision = timestamp_precision
114+
85115
def serialize_to_request(self, parameters, operation_model):
86116
"""Serialize parameters into an HTTP request.
87117
@@ -136,16 +166,33 @@ def _create_default_request(self):
136166
# Some extra utility methods subclasses can use.
137167

138168
def _timestamp_iso8601(self, value):
139-
if value.microsecond > 0:
140-
timestamp_format = ISO8601_MICRO
169+
"""Return ISO8601 timestamp with precision based on timestamp_precision."""
170+
# Smithy's standard is milliseconds, so we truncate the timestamp if the millisecond flag is set to true
171+
if self._timestamp_precision == TIMESTAMP_PRECISION_MILLISECOND:
172+
milliseconds = value.microsecond // 1000
173+
return (
174+
value.strftime('%Y-%m-%dT%H:%M:%S') + f'.{milliseconds:03d}Z'
175+
)
141176
else:
142-
timestamp_format = ISO8601
143-
return value.strftime(timestamp_format)
177+
# Otherwise we continue supporting microseconds in iso8601 for legacy reasons
178+
if value.microsecond > 0:
179+
timestamp_format = ISO8601_MICRO
180+
else:
181+
timestamp_format = ISO8601
182+
return value.strftime(timestamp_format)
144183

145184
def _timestamp_unixtimestamp(self, value):
146-
return int(calendar.timegm(value.timetuple()))
185+
"""Return unix timestamp with precision based on timestamp_precision."""
186+
# As of the addition of the precision flag, we support millisecond precision here as well
187+
if self._timestamp_precision == TIMESTAMP_PRECISION_MILLISECOND:
188+
base_timestamp = calendar.timegm(value.timetuple())
189+
milliseconds = (value.microsecond // 1000) / 1000.0
190+
return base_timestamp + milliseconds
191+
else:
192+
return int(calendar.timegm(value.timetuple()))
147193

148194
def _timestamp_rfc822(self, value):
195+
"""Return RFC822 timestamp (always second precision - RFC doesn't support sub-second)."""
149196
if isinstance(value, datetime.datetime):
150197
value = self._timestamp_unixtimestamp(value)
151198
return formatdate(value, usegmt=True)

tests/unit/botocore/test_serialize.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@
2323
from botocore.exceptions import ParamValidationError
2424
from botocore.model import ServiceModel
2525
from botocore.serialize import SERIALIZERS
26+
from botocore.serialize import (
27+
TIMESTAMP_PRECISION_DEFAULT,
28+
TIMESTAMP_PRECISION_MILLISECOND,
29+
)
2630

2731
from tests import unittest
2832

@@ -614,3 +618,120 @@ def test_restxml_serializes_unicode(self):
614618
self.serialize_to_request(params)
615619
except UnicodeEncodeError:
616620
self.fail("RestXML serializer failed to serialize unicode text.")
621+
622+
623+
class TestTimestampPrecisionParameter(unittest.TestCase):
624+
def setUp(self):
625+
self.model = {
626+
'metadata': {'protocol': 'query', 'apiVersion': '2014-01-01'},
627+
'documentation': '',
628+
'operations': {
629+
'TestOperation': {
630+
'name': 'TestOperation',
631+
'http': {
632+
'method': 'POST',
633+
'requestUri': '/',
634+
},
635+
'input': {'shape': 'InputShape'},
636+
}
637+
},
638+
'shapes': {
639+
'InputShape': {
640+
'type': 'structure',
641+
'members': {
642+
'UnixTimestamp': {'shape': 'UnixTimestampType'},
643+
'IsoTimestamp': {'shape': 'IsoTimestampType'},
644+
'Rfc822Timestamp': {'shape': 'Rfc822TimestampType'},
645+
},
646+
},
647+
'IsoTimestampType': {
648+
'type': 'timestamp',
649+
"timestampFormat": "iso8601",
650+
},
651+
'UnixTimestampType': {
652+
'type': 'timestamp',
653+
"timestampFormat": "unixTimestamp",
654+
},
655+
'Rfc822TimestampType': {
656+
'type': 'timestamp',
657+
"timestampFormat": "rfc822",
658+
},
659+
},
660+
}
661+
self.service_model = ServiceModel(self.model)
662+
663+
def serialize_to_request(
664+
self, input_params, timestamp_precision=TIMESTAMP_PRECISION_DEFAULT
665+
):
666+
request_serializer = serialize.create_serializer(
667+
self.service_model.metadata['protocol'],
668+
timestamp_precision=timestamp_precision,
669+
)
670+
return request_serializer.serialize_to_request(
671+
input_params, self.service_model.operation_model('TestOperation')
672+
)
673+
674+
def test_second_precision_maintains_existing_behavior(self):
675+
test_datetime = datetime.datetime(2024, 1, 1, 12, 0, 0, 123456)
676+
request = self.serialize_to_request(
677+
{'UnixTimestamp': test_datetime, 'IsoTimestamp': test_datetime}
678+
)
679+
# To maintain backwards compatibility, unix should not include milliseconds by default
680+
self.assertEqual(1704110400, request['body']['UnixTimestamp'])
681+
682+
# ISO always supported microseconds, so we need to continue supporting this
683+
self.assertEqual(
684+
'2024-01-01T12:00:00.123456Z',
685+
request['body']['IsoTimestamp'],
686+
)
687+
688+
def test_millisecond_precision_serialization(self):
689+
test_datetime = datetime.datetime(2024, 1, 1, 12, 0, 0, 123456)
690+
691+
# Check that millisecond precision is used when it is opted in to via the input param
692+
request = self.serialize_to_request(
693+
{'UnixTimestamp': test_datetime, 'IsoTimestamp': test_datetime},
694+
TIMESTAMP_PRECISION_MILLISECOND,
695+
)
696+
self.assertEqual(1704110400.123, request['body']['UnixTimestamp'])
697+
self.assertEqual(
698+
'2024-01-01T12:00:00.123Z',
699+
request['body']['IsoTimestamp'],
700+
)
701+
702+
def test_millisecond_precision_with_zero_microseconds(self):
703+
test_datetime = datetime.datetime(2024, 1, 1, 12, 0, 0, 0)
704+
705+
request = self.serialize_to_request(
706+
{'UnixTimestamp': test_datetime, 'IsoTimestamp': test_datetime},
707+
TIMESTAMP_PRECISION_MILLISECOND,
708+
)
709+
self.assertEqual(1704110400.0, request['body']['UnixTimestamp'])
710+
self.assertEqual(
711+
'2024-01-01T12:00:00.000Z',
712+
request['body']['IsoTimestamp'],
713+
)
714+
715+
def test_rfc822_timestamp_always_uses_second_precision(self):
716+
# RFC822 format doesn't support sub-second precision.
717+
test_datetime = datetime.datetime(2024, 1, 1, 12, 0, 0, 123456)
718+
request_second = self.serialize_to_request(
719+
{'Rfc822Timestamp': test_datetime},
720+
)
721+
request_milli = self.serialize_to_request(
722+
{'Rfc822Timestamp': test_datetime}, TIMESTAMP_PRECISION_MILLISECOND
723+
)
724+
self.assertEqual(
725+
request_second['body']['Rfc822Timestamp'],
726+
request_milli['body']['Rfc822Timestamp'],
727+
)
728+
self.assertIn('2024', request_second['body']['Rfc822Timestamp'])
729+
self.assertIn('GMT', request_second['body']['Rfc822Timestamp'])
730+
731+
def test_invalid_timestamp_precision_raises_error(self):
732+
with self.assertRaises(ValueError) as context:
733+
serialize.create_serializer(
734+
self.service_model.metadata['protocol'],
735+
timestamp_precision='invalid',
736+
)
737+
self.assertIn("Invalid timestamp precision", str(context.exception))

0 commit comments

Comments
 (0)