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
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,8 @@ public static String getTimestampOutputParam(
return format("ensure_utc(datetime.fromisoformat(expect_type(str, %s)))", dataSource);
}
case EPOCH_SECONDS -> {
writer.addStdlibImport("datetime", "datetime");
writer.addStdlibImport("datetime", "timezone");
return format("datetime.fromtimestamp(expect_type(int | float, %s), timezone.utc)", dataSource);
writer.addImport("smithy_python.utils", "epoch_seconds_to_datetime");
return format("epoch_seconds_to_datetime(expect_type(int | float, %s))", dataSource);
}
case HTTP_DATE -> {
writer.addImport("smithy_python.utils", "ensure_utc");
Expand Down
18 changes: 17 additions & 1 deletion python-packages/smithy-python/smithy_python/utils.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import re
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from math import isinf, isnan
from types import UnionType
Expand Down Expand Up @@ -49,6 +49,22 @@ def limited_parse_float(value: Any) -> float:
return expect_type(float, value)


def epoch_seconds_to_datetime(value: int | float) -> datetime:
"""Parse numerical epoch timestamps (seconds since 1970) into a datetime in UTC.

Falls back to using ``timedelta`` when ``fromtimestamp`` raises ``OverflowError``.
From Python's ``fromtimestamp`` documentation: "This may raise OverflowError, if the
timestamp is out of the range of values supported by the platform C localtime()
function, and OSError on localtime() failure. It's common for this to be restricted
to years from 1970 through 2038." This affects 32-bit systems.
"""
try:
return datetime.fromtimestamp(value, tz=timezone.utc)
except OverflowError:
epoch_zero = datetime(1970, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
return epoch_zero + timedelta(seconds=value)


_T = TypeVar("_T")


Expand Down
90 changes: 75 additions & 15 deletions python-packages/smithy-python/tests/unit/test_utils.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
# Copyright 2022 Amazon.com, Inc. or its affiliates. All Rights Reserved.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

To preempt the usual comment: I know, it's 2023. This notice should have been added here in 2022 when the file was first created, so backdating it.

#
# Licensed under the Apache License, Version 2.0 (the "License"). You
# may not use this file except in compliance with the License. A copy of
# the License is located at
#
# http://aws.amazon.com/apache2.0/
#
# or in the "license" file accompanying this file. This file is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.

# mypy: allow-untyped-defs
# mypy: allow-incomplete-defs
Comment on lines +14 to +15
Copy link
Contributor Author

Choose a reason for hiding this comment

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

To date, this is the best solution we have for test files that include pytest fixtures.


from datetime import datetime, timedelta, timezone
from decimal import Decimal
from math import isnan
from typing import Any
from typing import Any, NamedTuple
from unittest.mock import Mock

import pytest

from smithy_python.exceptions import ExpectationNotMetException
from smithy_python.utils import (
ensure_utc,
epoch_seconds_to_datetime,
expect_type,
limited_parse_float,
serialize_epoch_seconds,
Expand Down Expand Up @@ -181,29 +199,71 @@ def test_serialize_float(given: float | Decimal, expected: str) -> None:
assert serialize_float(given) == expected


class DateTimeTestcase(NamedTuple):
dt_object: datetime
rfc3339_str: str
epoch_seconds_num: int | float
epoch_seconds_str: str


DATETIME_TEST_CASES: list[DateTimeTestcase] = [
DateTimeTestcase(
dt_object=datetime(2017, 1, 1, tzinfo=timezone.utc),
rfc3339_str="2017-01-01T00:00:00Z",
epoch_seconds_num=1483228800,
epoch_seconds_str="1483228800",
),
DateTimeTestcase(
dt_object=datetime(2017, 1, 1, microsecond=1, tzinfo=timezone.utc),
rfc3339_str="2017-01-01T00:00:00.000001Z",
epoch_seconds_num=1483228800.000001,
epoch_seconds_str="1483228800.000001",
),
DateTimeTestcase(
dt_object=datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc),
rfc3339_str="1969-12-31T23:59:59Z",
epoch_seconds_num=-1,
epoch_seconds_str="-1",
),
# The first second affected by the Year 2038 problem where fromtimestamp raises an
# OverflowError on 32-bit systems for dates beyond 2038-01-19 03:14:07 UTC.
DateTimeTestcase(
dt_object=datetime(2038, 1, 19, 3, 14, 8, tzinfo=timezone.utc),
rfc3339_str="2038-01-19T03:14:08Z",
epoch_seconds_num=2147483648,
epoch_seconds_str="2147483648",
),
]


@pytest.mark.parametrize(
"given, expected",
[
(datetime(2017, 1, 1, tzinfo=timezone.utc), "2017-01-01T00:00:00Z"),
(
datetime(2017, 1, 1, microsecond=1, tzinfo=timezone.utc),
"2017-01-01T00:00:00.000001Z",
),
],
[(case.dt_object, case.rfc3339_str) for case in DATETIME_TEST_CASES],
)
def test_serialize_rfc3339(given: datetime, expected: str) -> None:
assert serialize_rfc3339(given) == expected


@pytest.mark.parametrize(
"given, expected",
[
(datetime(2017, 1, 1, tzinfo=timezone.utc), "1483228800"),
(
datetime(2017, 1, 1, microsecond=1, tzinfo=timezone.utc),
"1483228800.000001",
),
],
[(case.dt_object, case.epoch_seconds_str) for case in DATETIME_TEST_CASES],
)
def test_serialize_epoch_seconds(given: datetime, expected: str) -> None:
assert serialize_epoch_seconds(given) == expected


@pytest.mark.parametrize(
"given, expected",
[(case.epoch_seconds_num, case.dt_object) for case in DATETIME_TEST_CASES],
)
def test_epoch_seconds_to_datetime(given: int | float, expected: datetime) -> None:
assert epoch_seconds_to_datetime(given) == expected


def test_epoch_seconds_to_datetime_with_overflow_error(monkeypatch):
# Emulate the Year 2038 problem by always raising an OverflowError.
datetime_mock = Mock(wraps=datetime)
datetime_mock.fromtimestamp = Mock(side_effect=OverflowError())
monkeypatch.setattr("smithy_python.utils.datetime", datetime_mock)
dt_object = datetime(2038, 1, 19, 3, 14, 8, tzinfo=timezone.utc)
epoch_seconds_to_datetime(2147483648) == dt_object