Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion awscli/botocore/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
9 changes: 9 additions & 0 deletions awscli/botocore/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down
63 changes: 55 additions & 8 deletions awscli/botocore/serialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}"
)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we remove this extra newline to match the formatting in botocore?

self._timestamp_precision = timestamp_precision

def serialize_to_request(self, parameters, operation_model):
"""Serialize parameters into an HTTP request.

Expand Down Expand Up @@ -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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: missing one-line comment, to align with the botocore PR

value = self._timestamp_unixtimestamp(value)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this line should be updated to match the botocore PR

return formatdate(value, usegmt=True)
Expand Down
121 changes: 121 additions & 0 deletions tests/unit/botocore/test_serialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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))