diff --git a/examples/all_types/all_types_panel.py b/examples/all_types/all_types_panel.py index 529d39b2..8c84eee9 100644 --- a/examples/all_types/all_types_panel.py +++ b/examples/all_types/all_types_panel.py @@ -1,5 +1,6 @@ """A Streamlit visualization panel for the all_types.py example script.""" +import datetime as dt from enum import Enum, Flag import streamlit as st @@ -35,6 +36,11 @@ st.number_input(label=name, value=default_value, key=name, format="%.2f") elif isinstance(default_value, str): st.text_input(label=name, value=default_value, key=name) + elif isinstance(default_value, dt.datetime): + date = st.date_input(label="date", value=default_value) + time = st.time_input(label="time", value=default_value) + datetime = dt.datetime.combine(date, time) + panel.set_value(name, datetime) with col3: st.write(panel.get_value(name, default_value=default_value)) diff --git a/examples/all_types/define_types.py b/examples/all_types/define_types.py index 33c8c47c..937abc4d 100644 --- a/examples/all_types/define_types.py +++ b/examples/all_types/define_types.py @@ -1,5 +1,6 @@ """Define types.""" +import datetime as dt import enum import numpy as np @@ -70,6 +71,7 @@ class MyMixedEnum(enum.Enum): "float": 13.12, "int": 42, "str": "sample string", + "dt_datetime": dt.datetime.now(), # supported enum and flag types "intflags": MyIntFlags.VALUE1 | MyIntFlags.VALUE4, "intenum": MyIntEnum.VALUE20, diff --git a/src/nipanel/_convert.py b/src/nipanel/_convert.py index 61a48587..6de5eff7 100644 --- a/src/nipanel/_convert.py +++ b/src/nipanel/_convert.py @@ -12,6 +12,7 @@ from nipanel.converters.builtin import ( BoolConverter, BytesConverter, + DTDateTimeConverter, FloatConverter, IntConverter, StrConverter, @@ -37,6 +38,7 @@ FloatConverter(), IntConverter(), StrConverter(), + DTDateTimeConverter(), # Containers next BoolCollectionConverter(), BytesCollectionConverter(), diff --git a/src/nipanel/converters/builtin.py b/src/nipanel/converters/builtin.py index c004315c..50c9b932 100644 --- a/src/nipanel/converters/builtin.py +++ b/src/nipanel/converters/builtin.py @@ -1,9 +1,10 @@ """Classes to convert between builtin Python scalars and containers.""" +import datetime as dt from collections.abc import Collection from typing import Type -from google.protobuf import wrappers_pb2 +from google.protobuf import timestamp_pb2, wrappers_pb2 from ni.panels.v1 import panel_types_pb2 from nipanel.converters import Converter @@ -119,6 +120,30 @@ def to_python_value(self, protobuf_message: wrappers_pb2.StringValue) -> str: return protobuf_message.value +class DTDateTimeConverter(Converter[dt.datetime, timestamp_pb2.Timestamp]): + """A converter for datetime.datetime types.""" + + @property + def python_typename(self) -> str: + """The Python type that this converter handles.""" + return dt.datetime.__name__ + + @property + def protobuf_message(self) -> Type[timestamp_pb2.Timestamp]: + """The type-specific protobuf message for the Python type.""" + return timestamp_pb2.Timestamp + + def to_protobuf_message(self, python_value: dt.datetime) -> timestamp_pb2.Timestamp: + """Convert the Python dt.datetime to a protobuf timestamp_pb2.Timestamp.""" + ts = self.protobuf_message() + ts.FromDatetime(python_value) + return ts + + def to_python_value(self, protobuf_message: timestamp_pb2.Timestamp) -> dt.datetime: + """Convert the protobuf timestamp_pb2.Timestamp to a Python dt.datetime.""" + return protobuf_message.ToDatetime() + + class BoolCollectionConverter(Converter[Collection[bool], panel_types_pb2.BoolCollection]): """A converter for a Collection of bools.""" diff --git a/tests/unit/test_convert.py b/tests/unit/test_convert.py index a68131c0..ea20befc 100644 --- a/tests/unit/test_convert.py +++ b/tests/unit/test_convert.py @@ -1,8 +1,9 @@ +import datetime as dt from typing import Any, Collection, Union import numpy as np import pytest -from google.protobuf import any_pb2, wrappers_pb2 +from google.protobuf import any_pb2, timestamp_pb2, wrappers_pb2 from google.protobuf.message import Message from ni.panels.v1 import panel_types_pb2 from ni.protobuf.types.scalar_pb2 import ScalarData @@ -53,6 +54,7 @@ (tests.types.MixinIntEnum.VALUE11, "int"), (tests.types.MyStrEnum.VALUE1, "str"), (tests.types.MixinStrEnum.VALUE11, "str"), + (dt.datetime.now(), "datetime"), ([False, False], "Collection.bool"), ([b"mystr", b"mystr"], "Collection.bytes"), ([456.2, 1.0], "Collection.float"), @@ -123,6 +125,16 @@ def test___python_builtin_scalar___to_any___valid_wrapperpb2_value( assert unpack_dest.value == expected_value +def test___python_datetime_datetime___to_any___valid_timestamppb2_value() -> None: + expected_value = dt.datetime.now() + result = nipanel._convert.to_any(expected_value) + unpack_dest = timestamp_pb2.Timestamp() + _assert_any_and_unpack(result, unpack_dest) + + assert isinstance(unpack_dest, timestamp_pb2.Timestamp) + assert unpack_dest.ToDatetime() == expected_value + + @pytest.mark.parametrize( "proto_type, default_value, expected_value", [ @@ -175,6 +187,18 @@ def test___wrapperpb2_value___from_any___valid_python_value( assert result == expected_value +def test___timestamppb2_timestamp___from_any___valid_python_value() -> None: + expected_value = dt.datetime.now() + pb_value = timestamp_pb2.Timestamp() + pb_value.FromDatetime(expected_value) + packed_any = _pack_into_any(pb_value) + + result = nipanel._convert.from_any(packed_any) + + assert isinstance(result, dt.datetime) + assert result == expected_value + + @pytest.mark.parametrize( "proto_type, expected_value", [ diff --git a/tests/unit/test_streamlit_panel.py b/tests/unit/test_streamlit_panel.py index 027a7c40..860bacf8 100644 --- a/tests/unit/test_streamlit_panel.py +++ b/tests/unit/test_streamlit_panel.py @@ -1,5 +1,5 @@ +import datetime as dt import enum -from datetime import datetime import grpc import pytest @@ -266,6 +266,7 @@ def test___set_string_enum_type___get_value_with_int_enum_default___raises_excep 3.14, True, b"robotext", + dt.datetime.now(), ], ) def test___builtin_scalar_type___set_value___gets_same_value( @@ -312,7 +313,6 @@ def test___enum_type___set_value___gets_same_value( @pytest.mark.parametrize( "value_payload", [ - datetime.now(), lambda x: x + 1, [1, "string"], ["string", []],