Skip to content

Commit a0baad7

Browse files
committed
Add timestamp <-> datetime conversion functions
These functions are copied from `frequenz-client-base` and renamed to match the naming convention in this project. The functions in `frequenz-client-base` will be eventually deprecated. Signed-off-by: Leandro Lucarella <[email protected]>
1 parent 9ade113 commit a0baad7

File tree

4 files changed

+144
-0
lines changed

4 files changed

+144
-0
lines changed

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies = [
2929
"typing-extensions >= 4.13.0, < 5",
3030
"frequenz-api-common >= 0.8.0, < 1",
3131
"frequenz-core >= 1.0.2, < 2",
32+
"protobuf >= 6.33.1, < 7",
3233
]
3334
dynamic = ["version"]
3435

@@ -60,6 +61,7 @@ dev-mkdocs = [
6061
dev-mypy = [
6162
"mypy == 1.18.2",
6263
"types-Markdown == 3.9.0.20250906",
64+
"types-protobuf == 6.32.1.20251105",
6365
# For checking the noxfile, docs/ script, and tests
6466
"frequenz-client-common[dev-mkdocs,dev-noxfile,dev-pytest]",
6567
]
@@ -72,6 +74,7 @@ dev-pylint = [
7274
dev-pytest = [
7375
"pytest == 8.4.2",
7476
"frequenz-repo-config[extra-lint-examples] == 0.13.6",
77+
"hypothesis == 6.140.3",
7578
"pytest-mock == 3.15.1",
7679
"pytest-asyncio == 1.2.0",
7780
"async-solipsism == 0.8",

src/frequenz/client/common/proto/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44
"""General utilities for converting common types to/from protobuf types."""
55

66
from ._enum import enum_from_proto
7+
from ._timestamp import datetime_from_proto, datetime_to_proto
78

89
__all__ = [
910
"enum_from_proto",
11+
"datetime_from_proto",
12+
"datetime_to_proto",
1013
]
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Helper functions to convert protobuf Timestamp <-> Python datetime."""
5+
6+
from datetime import datetime, timezone
7+
from typing import overload
8+
9+
from google.protobuf import timestamp_pb2
10+
11+
12+
@overload
13+
def datetime_to_proto(dt: datetime) -> timestamp_pb2.Timestamp:
14+
"""Convert a datetime to a protobuf Timestamp.
15+
16+
Args:
17+
dt: datetime object to convert
18+
19+
Returns:
20+
datetime converted to Timestamp
21+
"""
22+
23+
24+
@overload
25+
def datetime_to_proto(dt: None) -> None:
26+
"""Overload to handle None values.
27+
28+
Args:
29+
dt: None
30+
31+
Returns:
32+
None
33+
"""
34+
35+
36+
def datetime_to_proto(dt: datetime | None) -> timestamp_pb2.Timestamp | None:
37+
"""Convert a datetime to a protobuf Timestamp.
38+
39+
Returns None if dt is None.
40+
41+
Args:
42+
dt: datetime object to convert
43+
44+
Returns:
45+
datetime converted to Timestamp
46+
"""
47+
if dt is None:
48+
return None
49+
50+
ts = timestamp_pb2.Timestamp()
51+
ts.FromDatetime(dt)
52+
return ts
53+
54+
55+
def datetime_from_proto(
56+
ts: timestamp_pb2.Timestamp, tz: timezone = timezone.utc
57+
) -> datetime:
58+
"""Convert a protobuf Timestamp to a datetime.
59+
60+
Args:
61+
ts: Timestamp object to convert
62+
tz: Timezone to use for the datetime
63+
64+
Returns:
65+
Timestamp converted to datetime
66+
"""
67+
# Add microseconds and add nanoseconds converted to microseconds
68+
microseconds = int(ts.nanos / 1000)
69+
return datetime.fromtimestamp(ts.seconds + microseconds * 1e-6, tz=tz)

tests/proto/test_datetime.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# License: MIT
2+
# Copyright © 2024 Frequenz Energy-as-a-Service GmbH
3+
4+
"""Test conversion helper functions."""
5+
6+
from datetime import datetime, timezone
7+
8+
# pylint: disable=no-name-in-module
9+
from google.protobuf.timestamp_pb2 import Timestamp
10+
11+
# pylint: enable=no-name-in-module
12+
from hypothesis import given
13+
from hypothesis import strategies as st
14+
15+
from frequenz.client.common.proto import datetime_from_proto, datetime_to_proto
16+
17+
# Strategy for generating datetime objects
18+
datetime_strategy = st.datetimes(
19+
min_value=datetime(1970, 1, 1),
20+
max_value=datetime(9999, 12, 31),
21+
timezones=st.just(timezone.utc),
22+
)
23+
24+
# Strategy for generating Timestamp objects
25+
timestamp_strategy = st.builds(
26+
Timestamp,
27+
seconds=st.integers(
28+
min_value=0,
29+
max_value=int(datetime(9999, 12, 31, tzinfo=timezone.utc).timestamp()),
30+
),
31+
)
32+
33+
34+
@given(datetime_strategy)
35+
def test_to_timestamp_with_datetime(dt: datetime) -> None:
36+
"""Test conversion from datetime to Timestamp."""
37+
ts = datetime_to_proto(dt)
38+
assert ts is not None
39+
converted_back_dt = datetime_from_proto(ts)
40+
assert dt.tzinfo == converted_back_dt.tzinfo
41+
assert dt.timestamp() == converted_back_dt.timestamp()
42+
43+
44+
def test_to_timestamp_with_none() -> None:
45+
"""Test that passing None returns None."""
46+
assert datetime_to_proto(None) is None
47+
48+
49+
@given(timestamp_strategy)
50+
def test_to_datetime(ts: Timestamp) -> None:
51+
"""Test conversion from Timestamp to datetime."""
52+
dt = datetime_from_proto(ts)
53+
assert dt is not None
54+
# Convert back to Timestamp and compare
55+
converted_back_ts = datetime_to_proto(dt)
56+
assert ts.seconds == converted_back_ts.seconds
57+
58+
59+
@given(datetime_strategy)
60+
def test_no_none_datetime(dt: datetime) -> None:
61+
"""Test behavior of type hinting."""
62+
ts: Timestamp = datetime_to_proto(dt)
63+
dt_none: datetime | None = None
64+
65+
# The test would fail without the ignore comment as it should.
66+
ts2: Timestamp = datetime_to_proto(dt_none) # type: ignore
67+
68+
assert ts is not None
69+
assert ts2 is None

0 commit comments

Comments
 (0)