From 103bf0f1126936eb70e29cb6255a5aafb4a68043 Mon Sep 17 00:00:00 2001 From: "Mathias L. Baumann" Date: Mon, 19 Feb 2024 18:46:22 +0100 Subject: [PATCH] Add datetime and Timestamp conversion functions Signed-off-by: Mathias L. Baumann --- RELEASE_NOTES.md | 2 +- pyproject.toml | 2 + src/frequenz/client/base/conversion_helper.py | 70 +++++++++++++++++++ tests/test_conversion.py | 69 ++++++++++++++++++ 4 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 src/frequenz/client/base/conversion_helper.py create mode 100644 tests/test_conversion.py diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index b5b5218..8825f15 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -10,7 +10,7 @@ ## New Features - +* Functions to convert to `datetime` and protobufs `Timestamp` have been added. ## Bug Fixes diff --git a/pyproject.toml b/pyproject.toml index 5f28ad6..a41b7b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,7 @@ dev-mkdocs = [ dev-mypy = [ "mypy == 1.8.0", "types-Markdown == 3.5.0.20240129", + "types-protobuf == 4.24.0.20240129", "grpc-stubs == 1.53.0.5", # This dependency introduces breaking changes in patch releases # For checking the noxfile, docs/ script, and tests "frequenz-client-base[dev-mkdocs,dev-noxfile,dev-pytest]", @@ -82,6 +83,7 @@ dev-pytest = [ "pytest-mock == 3.12.0", "pytest-asyncio == 0.23.4", "async-solipsism == 0.5", + "hypothesis == 6.98.8", ] dev = [ "frequenz-client-base[dev-mkdocs,dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]", diff --git a/src/frequenz/client/base/conversion_helper.py b/src/frequenz/client/base/conversion_helper.py new file mode 100644 index 0000000..04fa26a --- /dev/null +++ b/src/frequenz/client/base/conversion_helper.py @@ -0,0 +1,70 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Helper functions to convert to/from common python types.""" + +from datetime import datetime, timezone +from typing import overload + +# pylint: disable=no-name-in-module +from google.protobuf.timestamp_pb2 import Timestamp + +# pylint: enable=no-name-in-module + + +@overload +def to_timestamp(dt: datetime) -> Timestamp: + """Convert a datetime to a protobuf Timestamp. + + Args: + dt: datetime object to convert + + Returns: + datetime converted to Timestamp + """ + + +@overload +def to_timestamp(dt: None) -> None: + """Overload to handle None values. + + Args: + dt: None + + Returns: + None + """ + + +def to_timestamp(dt: datetime | None) -> Timestamp | None: + """Convert a datetime to a protobuf Timestamp. + + Returns None if dt is None. + + Args: + dt: datetime object to convert + + Returns: + datetime converted to Timestamp + """ + if dt is None: + return None + + ts = Timestamp() + ts.FromDatetime(dt) + return ts + + +def to_datetime(ts: Timestamp, tz: timezone = timezone.utc) -> datetime: + """Convert a protobuf Timestamp to a datetime. + + Args: + ts: Timestamp object to convert + tz: Timezone to use for the datetime + + Returns: + Timestamp converted to datetime + """ + # Add microseconds and add nanoseconds converted to microseconds + microseconds = int(ts.nanos / 1000) + return datetime.fromtimestamp(ts.seconds + microseconds * 1e-6, tz=tz) diff --git a/tests/test_conversion.py b/tests/test_conversion.py new file mode 100644 index 0000000..aed89cd --- /dev/null +++ b/tests/test_conversion.py @@ -0,0 +1,69 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Test conversion helper functions.""" + +from datetime import datetime, timezone + +# pylint: disable=no-name-in-module +from google.protobuf.timestamp_pb2 import Timestamp + +# pylint: enable=no-name-in-module +from hypothesis import given +from hypothesis import strategies as st + +from frequenz.client.base.conversion_helper import to_datetime, to_timestamp + +# Strategy for generating datetime objects +datetime_strategy = st.datetimes( + min_value=datetime(1970, 1, 1), + max_value=datetime(9999, 12, 31), + timezones=st.just(timezone.utc), +) + +# Strategy for generating Timestamp objects +timestamp_strategy = st.builds( + Timestamp, + seconds=st.integers( + min_value=0, + max_value=int(datetime(9999, 12, 31, tzinfo=timezone.utc).timestamp()), + ), +) + + +@given(datetime_strategy) +def test_to_timestamp_with_datetime(dt: datetime) -> None: + """Test conversion from datetime to Timestamp.""" + ts = to_timestamp(dt) + assert ts is not None + converted_back_dt = to_datetime(ts) + assert dt.tzinfo == converted_back_dt.tzinfo + assert dt.timestamp() == converted_back_dt.timestamp() + + +def test_to_timestamp_with_none() -> None: + """Test that passing None returns None.""" + assert to_timestamp(None) is None + + +@given(timestamp_strategy) +def test_to_datetime(ts: Timestamp) -> None: + """Test conversion from Timestamp to datetime.""" + dt = to_datetime(ts) + assert dt is not None + # Convert back to Timestamp and compare + converted_back_ts = to_timestamp(dt) + assert ts.seconds == converted_back_ts.seconds + + +@given(datetime_strategy) +def test_no_none_datetime(dt: datetime) -> None: + """Test behavior of type hinting.""" + ts: Timestamp = to_timestamp(dt) + dt_none: datetime | None = None + + # The test would fail without the ignore comment as it should. + ts2: Timestamp = to_timestamp(dt_none) # type: ignore + + assert ts is not None + assert ts2 is None