Skip to content
Merged
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
16 changes: 15 additions & 1 deletion botocore/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,22 @@ 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
10 changes: 10 additions & 0 deletions botocore/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,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 @@ -1069,6 +1070,11 @@ def remove_bedrock_runtime_invoke_model_with_bidirectional_stream(
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 add_retry_headers(request, **kwargs):
retries_context = request.context.get('retries')
if not retries_context:
Expand Down Expand Up @@ -1471,6 +1477,10 @@ def get_bearer_auth_supported_services():
'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
65 changes: 57 additions & 8 deletions botocore/serialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,34 @@
ISO8601_MICRO = '%Y-%m-%dT%H:%M:%S.%fZ'
HOST_PREFIX_RE = re.compile(r"^[A-Za-z0-9\.\-]+$")

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):
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.
"""
# TODO: Unknown protocols.
serializer = SERIALIZERS[protocol_name]()
serializer = SERIALIZERS[protocol_name](
timestamp_precision=timestamp_precision
)
if include_validation:
validator = validate.ParamValidator()
serializer = validate.ParamValidationDecorator(validator, serializer)
Expand All @@ -85,6 +109,13 @@ 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.

Expand Down Expand Up @@ -139,18 +170,36 @@ 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)."""
# RFC 2822 doesn't support sub-second precision, so always use second precision format
if isinstance(value, datetime.datetime):
value = self._timestamp_unixtimestamp(value)
value = int(calendar.timegm(value.timetuple()))
return formatdate(value, usegmt=True)

def _convert_timestamp_to_str(self, value, timestamp_format=None):
Expand Down
121 changes: 121 additions & 0 deletions tests/unit/test_serialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
from botocore import serialize
from botocore.exceptions import ParamValidationError
from botocore.model import ServiceModel
from botocore.serialize import (
TIMESTAMP_PRECISION_DEFAULT,
TIMESTAMP_PRECISION_MILLISECOND,
)
from tests import unittest


Expand Down Expand Up @@ -616,3 +620,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))