Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add timestamp format enum #248

Merged
merged 2 commits into from
May 23, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
70 changes: 70 additions & 0 deletions python-packages/smithy-core/smithy_core/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,20 @@
# SPDX-License-Identifier: Apache-2.0
import json
from collections.abc import Mapping, Sequence
from datetime import datetime
from email.utils import format_datetime, parsedate_to_datetime
nateprewitt marked this conversation as resolved.
Show resolved Hide resolved
from enum import Enum
from typing import Any, TypeAlias

from .exceptions import ExpectationNotMetException
from .utils import (
ensure_utc,
epoch_seconds_to_datetime,
expect_type,
serialize_epoch_seconds,
serialize_rfc3339,
)

Document: TypeAlias = (
Mapping[str, "Document"] | Sequence["Document"] | str | int | float | bool | None
)
Expand Down Expand Up @@ -41,3 +53,61 @@ def from_json(j: Any) -> "JsonBlob":
json_string = JsonBlob(json.dumps(j).encode(encoding="utf-8"))
json_string._json = j
return json_string


class TimestampFormat(Enum):
"""Smithy-defined timestamp formats with serialization and deserialization helpers.

See `Smithy's docs <https://smithy.io/2.0/spec/protocol-traits.html#smithy-api-timestampformat-trait>`_
for more details.
"""

DATE_TIME = "date-time"
"""RFC3339 section 5.6 datetime with optional millisecond precision but no UTC
offset."""

HTTP_DATE = "http-date"
"""An HTTP date as defined by the IMF-fixdate production in RFC 7231 section
7.1.1.1."""
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we be using the latest standard here (RFC 9110 5.6.7) or is there a notable difference in the older RFC we use?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think there's any difference, but I can update the reference here and in the smithy docs.

Copy link
Contributor Author

Choose a reason for hiding this comment

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


EPOCH_SECONDS = "epoch-seconds"
"""Also known as Unix time, the number of seconds that have elapsed since 00:00:00
Coordinated Universal Time (UTC), Thursday, 1 January 1970, with optional
millisecond precision."""

def serialize(self, value: datetime) -> str | float:
"""Serializes a datetime into the timestamp format.

:param value: The timestamp to serialize.
:returns: A formatted timestamp. This will be a float for EPOCH_SECONDS, or a
string otherwise.
"""
value = ensure_utc(value)
match self:
case TimestampFormat.EPOCH_SECONDS:
return serialize_epoch_seconds(value)
case TimestampFormat.HTTP_DATE:
return format_datetime(value, usegmt=True)
case TimestampFormat.DATE_TIME:
return serialize_rfc3339(value)

def deserialize(self, value: str | float) -> datetime:
"""Deserializes a datetime from a value of the format.

:param value: The timestamp value to deserialize. If the format is
EPOCH_SECONDS, the value must be an int or float, or a string containing an
int or float. Otherwise, it must be a string.
:returns: The provided value as a datetime instance.
"""
match self:
case TimestampFormat.EPOCH_SECONDS:
if isinstance(value, str):
try:
value = float(value)
except ValueError as e:
raise ExpectationNotMetException from e
return epoch_seconds_to_datetime(value=value)
case TimestampFormat.HTTP_DATE:
return ensure_utc(parsedate_to_datetime(expect_type(str, value)))
case TimestampFormat.DATE_TIME:
return ensure_utc(datetime.fromisoformat(expect_type(str, value)))
127 changes: 126 additions & 1 deletion python-packages/smithy-core/tests/unit/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
# SPDX-License-Identifier: Apache-2.0

# pyright: reportPrivateUsage=false
from smithy_core.types import JsonBlob, JsonString
from datetime import datetime, timezone

import pytest

from smithy_core.exceptions import ExpectationNotMetException
from smithy_core.types import JsonBlob, JsonString, TimestampFormat


def test_json_string() -> None:
Expand Down Expand Up @@ -55,3 +60,123 @@ def test_json_blob_is_lazy() -> None:
def test_blob_from_json_immediately_caches() -> None:
json_blob = JsonBlob.from_json({})
assert json_blob._json == {}


TIMESTAMP_FORMAT_SERIALIZATION_CASES: list[
tuple[TimestampFormat, float | str, datetime]
] = [
(
TimestampFormat.DATE_TIME,
"2017-01-01T00:00:00Z",
datetime(2017, 1, 1, tzinfo=timezone.utc),
),
(
TimestampFormat.EPOCH_SECONDS,
1483228800,
datetime(2017, 1, 1, tzinfo=timezone.utc),
),
(
TimestampFormat.HTTP_DATE,
"Sun, 01 Jan 2017 00:00:00 GMT",
datetime(2017, 1, 1, tzinfo=timezone.utc),
),
(
TimestampFormat.DATE_TIME,
"2017-01-01T00:00:00.000001Z",
datetime(2017, 1, 1, microsecond=1, tzinfo=timezone.utc),
),
(
TimestampFormat.EPOCH_SECONDS,
1483228800.000001,
datetime(2017, 1, 1, microsecond=1, tzinfo=timezone.utc),
),
(
TimestampFormat.DATE_TIME,
"1969-12-31T23:59:59Z",
datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc),
),
(
TimestampFormat.EPOCH_SECONDS,
-1,
datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc),
),
(
TimestampFormat.HTTP_DATE,
"Wed, 31 Dec 1969 23:59:59 GMT",
datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc),
),
(
TimestampFormat.DATE_TIME,
"2038-01-19T03:14:08Z",
datetime(2038, 1, 19, 3, 14, 8, tzinfo=timezone.utc),
),
(
TimestampFormat.EPOCH_SECONDS,
2147483648,
datetime(2038, 1, 19, 3, 14, 8, tzinfo=timezone.utc),
),
(
TimestampFormat.HTTP_DATE,
"Tue, 19 Jan 2038 03:14:08 GMT",
datetime(2038, 1, 19, 3, 14, 8, tzinfo=timezone.utc),
),
]

TIMESTAMP_FORMAT_DESERIALIZATION_CASES: list[
tuple[TimestampFormat, float | str, datetime]
] = [
(
TimestampFormat.EPOCH_SECONDS,
"1483228800",
datetime(2017, 1, 1, tzinfo=timezone.utc),
),
(
TimestampFormat.EPOCH_SECONDS,
"1483228800.000001",
datetime(2017, 1, 1, microsecond=1, tzinfo=timezone.utc),
),
(
TimestampFormat.EPOCH_SECONDS,
"-1",
datetime(1969, 12, 31, 23, 59, 59, tzinfo=timezone.utc),
),
(
TimestampFormat.EPOCH_SECONDS,
"2147483648",
datetime(2038, 1, 19, 3, 14, 8, tzinfo=timezone.utc),
),
]
TIMESTAMP_FORMAT_DESERIALIZATION_CASES.extend(TIMESTAMP_FORMAT_SERIALIZATION_CASES)


@pytest.mark.parametrize(
"format, serialized, deserialized", TIMESTAMP_FORMAT_SERIALIZATION_CASES
)
def test_timestamp_format_serialize(
format: TimestampFormat, serialized: str | float, deserialized: datetime
):
assert format.serialize(deserialized) == serialized


@pytest.mark.parametrize(
"format, serialized, deserialized", TIMESTAMP_FORMAT_DESERIALIZATION_CASES
)
def test_timestamp_format_deserialize(
format: TimestampFormat, serialized: str | float, deserialized: datetime
):
assert format.deserialize(serialized) == deserialized


@pytest.mark.parametrize(
"format, value",
[
(TimestampFormat.DATE_TIME, 1),
(TimestampFormat.HTTP_DATE, 1),
(TimestampFormat.EPOCH_SECONDS, "foo"),
],
)
def test_invalid_timestamp_format_type_raises(
format: TimestampFormat, value: str | float
):
with pytest.raises(ExpectationNotMetException):
format.deserialize(value)
Loading