diff --git a/awscli/botocore/args.py b/awscli/botocore/args.py index 422e7492f121..c88f7395a52b 100644 --- a/awscli/botocore/args.py +++ b/awscli/botocore/args.py @@ -146,8 +146,23 @@ def get_client_args( proxies_config=new_config.proxies_config, ) + + # Emit event to allow service-specific or customer customization of serializer kwargs + event_name = f'creating-serializer.{service_name}' + serializer_kwargs = { + 'timestamp_precision': botocore.serialize.TIMESTAMP_PRECISION_DEFAULT + } + event_emitter.emit( + event_name, + protocol_name=protocol, + service_model=service_model, + serializer_kwargs=serializer_kwargs, + ) + serializer = botocore.serialize.create_serializer( - protocol, parameter_validation + protocol, + parameter_validation, + timestamp_precision=serializer_kwargs['timestamp_precision'], ) response_parser = botocore.parsers.create_parser(protocol) diff --git a/awscli/botocore/handlers.py b/awscli/botocore/handlers.py index 6bca2d9c4b6b..4c538bea7474 100644 --- a/awscli/botocore/handlers.py +++ b/awscli/botocore/handlers.py @@ -53,6 +53,7 @@ UnsupportedTLSVersionWarning, ) from botocore.regions import EndpointResolverBuiltins +from botocore.serialize import TIMESTAMP_PRECISION_MILLISECOND from botocore.signers import ( add_dsql_generate_db_auth_token_methods, add_generate_db_auth_token, @@ -995,6 +996,10 @@ def remove_bedrock_runtime_invoke_model_with_bidirectional_stream( if 'invoke_model_with_bidirectional_stream' in class_attributes: del class_attributes['invoke_model_with_bidirectional_stream'] +def enable_millisecond_timestamp_precision(serializer_kwargs, **kwargs): + """Event handler to enable millisecond precision""" + serializer_kwargs['timestamp_precision'] = TIMESTAMP_PRECISION_MILLISECOND + def remove_bucket_from_url_paths_from_model(params, model, context, **kwargs): """Strips leading `{Bucket}/` from any operations that have it. @@ -1345,6 +1350,10 @@ def _set_extra_headers_for_unsigned_request( 'creating-client-class.bedrock-runtime', remove_bedrock_runtime_invoke_model_with_bidirectional_stream, ), + ( + 'creating-serializer.bedrock-agentcore', + enable_millisecond_timestamp_precision, + ), ('after-call.iam', json_decode_policies), ('after-call.ec2.GetConsoleOutput', decode_console_output), ('after-call.cloudformation.GetTemplate', json_decode_template_body), diff --git a/awscli/botocore/serialize.py b/awscli/botocore/serialize.py index 55a536cf35c7..986cf24a3e14 100644 --- a/awscli/botocore/serialize.py +++ b/awscli/botocore/serialize.py @@ -63,10 +63,32 @@ # Same as ISO8601, but with microsecond precision. ISO8601_MICRO = '%Y-%m-%dT%H:%M:%S.%fZ' +TIMESTAMP_PRECISION_DEFAULT = 'default' +TIMESTAMP_PRECISION_MILLISECOND = 'millisecond' +TIMESTAMP_PRECISION_OPTIONS = ( + TIMESTAMP_PRECISION_DEFAULT, + TIMESTAMP_PRECISION_MILLISECOND, +) -def create_serializer(protocol_name, include_validation=True): - # TODO: Unknown protocols. - serializer = SERIALIZERS[protocol_name]() +def create_serializer( + protocol_name, + include_validation=True, + timestamp_precision=TIMESTAMP_PRECISION_DEFAULT, + ): + """Create a serializer for the given protocol. + :param protocol_name: The protocol name to create a serializer for. + :type protocol_name: str + :param include_validation: Whether to include parameter validation. + :type include_validation: bool + :param timestamp_precision: Timestamp precision level. + - 'default': Microseconds for ISO timestamps, seconds for Unix and RFC + - 'millisecond': Millisecond precision (ISO/Unix), seconds for RFC + :type timestamp_precision: str + :return: A serializer instance for the given protocol. + """ + serializer = SERIALIZERS[protocol_name]( + timestamp_precision=timestamp_precision + ) if include_validation: validator = validate.ParamValidator() serializer = validate.ParamValidationDecorator(validator, serializer) @@ -82,6 +104,14 @@ class Serializer: MAP_TYPE = dict DEFAULT_ENCODING = 'utf-8' + def __init__(self, timestamp_precision=TIMESTAMP_PRECISION_DEFAULT): + if timestamp_precision not in TIMESTAMP_PRECISION_OPTIONS: + raise ValueError( + f"Invalid timestamp precision found while creating serializer: {timestamp_precision}" + ) + + self._timestamp_precision = timestamp_precision + def serialize_to_request(self, parameters, operation_model): """Serialize parameters into an HTTP request. @@ -136,16 +166,33 @@ def _create_default_request(self): # Some extra utility methods subclasses can use. def _timestamp_iso8601(self, value): - if value.microsecond > 0: - timestamp_format = ISO8601_MICRO + """Return ISO8601 timestamp with precision based on timestamp_precision.""" + # Smithy's standard is milliseconds, so we truncate the timestamp if the millisecond flag is set to true + if self._timestamp_precision == TIMESTAMP_PRECISION_MILLISECOND: + milliseconds = value.microsecond // 1000 + return ( + value.strftime('%Y-%m-%dT%H:%M:%S') + f'.{milliseconds:03d}Z' + ) else: - timestamp_format = ISO8601 - return value.strftime(timestamp_format) + # Otherwise we continue supporting microseconds in iso8601 for legacy reasons + if value.microsecond > 0: + timestamp_format = ISO8601_MICRO + else: + timestamp_format = ISO8601 + return value.strftime(timestamp_format) def _timestamp_unixtimestamp(self, value): - return int(calendar.timegm(value.timetuple())) + """Return unix timestamp with precision based on timestamp_precision.""" + # As of the addition of the precision flag, we support millisecond precision here as well + if self._timestamp_precision == TIMESTAMP_PRECISION_MILLISECOND: + base_timestamp = calendar.timegm(value.timetuple()) + milliseconds = (value.microsecond // 1000) / 1000.0 + return base_timestamp + milliseconds + else: + return int(calendar.timegm(value.timetuple())) def _timestamp_rfc822(self, value): + """Return RFC822 timestamp (always second precision - RFC doesn't support sub-second).""" if isinstance(value, datetime.datetime): value = self._timestamp_unixtimestamp(value) return formatdate(value, usegmt=True) diff --git a/tests/unit/botocore/test_serialize.py b/tests/unit/botocore/test_serialize.py index c05805f2e957..2294c8759b3c 100644 --- a/tests/unit/botocore/test_serialize.py +++ b/tests/unit/botocore/test_serialize.py @@ -23,6 +23,10 @@ from botocore.exceptions import ParamValidationError from botocore.model import ServiceModel from botocore.serialize import SERIALIZERS +from botocore.serialize import ( + TIMESTAMP_PRECISION_DEFAULT, + TIMESTAMP_PRECISION_MILLISECOND, +) from tests import unittest @@ -614,3 +618,120 @@ def test_restxml_serializes_unicode(self): self.serialize_to_request(params) except UnicodeEncodeError: self.fail("RestXML serializer failed to serialize unicode text.") + + +class TestTimestampPrecisionParameter(unittest.TestCase): + def setUp(self): + self.model = { + 'metadata': {'protocol': 'query', 'apiVersion': '2014-01-01'}, + 'documentation': '', + 'operations': { + 'TestOperation': { + 'name': 'TestOperation', + 'http': { + 'method': 'POST', + 'requestUri': '/', + }, + 'input': {'shape': 'InputShape'}, + } + }, + 'shapes': { + 'InputShape': { + 'type': 'structure', + 'members': { + 'UnixTimestamp': {'shape': 'UnixTimestampType'}, + 'IsoTimestamp': {'shape': 'IsoTimestampType'}, + 'Rfc822Timestamp': {'shape': 'Rfc822TimestampType'}, + }, + }, + 'IsoTimestampType': { + 'type': 'timestamp', + "timestampFormat": "iso8601", + }, + 'UnixTimestampType': { + 'type': 'timestamp', + "timestampFormat": "unixTimestamp", + }, + 'Rfc822TimestampType': { + 'type': 'timestamp', + "timestampFormat": "rfc822", + }, + }, + } + self.service_model = ServiceModel(self.model) + + def serialize_to_request( + self, input_params, timestamp_precision=TIMESTAMP_PRECISION_DEFAULT + ): + request_serializer = serialize.create_serializer( + self.service_model.metadata['protocol'], + timestamp_precision=timestamp_precision, + ) + return request_serializer.serialize_to_request( + input_params, self.service_model.operation_model('TestOperation') + ) + + def test_second_precision_maintains_existing_behavior(self): + test_datetime = datetime.datetime(2024, 1, 1, 12, 0, 0, 123456) + request = self.serialize_to_request( + {'UnixTimestamp': test_datetime, 'IsoTimestamp': test_datetime} + ) + # To maintain backwards compatibility, unix should not include milliseconds by default + self.assertEqual(1704110400, request['body']['UnixTimestamp']) + + # ISO always supported microseconds, so we need to continue supporting this + self.assertEqual( + '2024-01-01T12:00:00.123456Z', + request['body']['IsoTimestamp'], + ) + + def test_millisecond_precision_serialization(self): + test_datetime = datetime.datetime(2024, 1, 1, 12, 0, 0, 123456) + + # Check that millisecond precision is used when it is opted in to via the input param + request = self.serialize_to_request( + {'UnixTimestamp': test_datetime, 'IsoTimestamp': test_datetime}, + TIMESTAMP_PRECISION_MILLISECOND, + ) + self.assertEqual(1704110400.123, request['body']['UnixTimestamp']) + self.assertEqual( + '2024-01-01T12:00:00.123Z', + request['body']['IsoTimestamp'], + ) + + def test_millisecond_precision_with_zero_microseconds(self): + test_datetime = datetime.datetime(2024, 1, 1, 12, 0, 0, 0) + + request = self.serialize_to_request( + {'UnixTimestamp': test_datetime, 'IsoTimestamp': test_datetime}, + TIMESTAMP_PRECISION_MILLISECOND, + ) + self.assertEqual(1704110400.0, request['body']['UnixTimestamp']) + self.assertEqual( + '2024-01-01T12:00:00.000Z', + request['body']['IsoTimestamp'], + ) + + def test_rfc822_timestamp_always_uses_second_precision(self): + # RFC822 format doesn't support sub-second precision. + test_datetime = datetime.datetime(2024, 1, 1, 12, 0, 0, 123456) + request_second = self.serialize_to_request( + {'Rfc822Timestamp': test_datetime}, + ) + request_milli = self.serialize_to_request( + {'Rfc822Timestamp': test_datetime}, TIMESTAMP_PRECISION_MILLISECOND + ) + self.assertEqual( + request_second['body']['Rfc822Timestamp'], + request_milli['body']['Rfc822Timestamp'], + ) + self.assertIn('2024', request_second['body']['Rfc822Timestamp']) + self.assertIn('GMT', request_second['body']['Rfc822Timestamp']) + + def test_invalid_timestamp_precision_raises_error(self): + with self.assertRaises(ValueError) as context: + serialize.create_serializer( + self.service_model.metadata['protocol'], + timestamp_precision='invalid', + ) + self.assertIn("Invalid timestamp precision", str(context.exception))