From 207c898b1f78b49b2395735b98fe384e47db1b86 Mon Sep 17 00:00:00 2001 From: Michael Johansen Date: Thu, 31 Jul 2025 10:10:02 -0500 Subject: [PATCH 1/6] Add a converter for datetime.datetime <--> Timestamp. Signed-off-by: Michael Johansen --- src/nipanel/_convert.py | 2 ++ src/nipanel/converters/builtin.py | 27 ++++++++++++++++++++++++++- tests/unit/test_convert.py | 26 +++++++++++++++++++++++++- tests/unit/test_streamlit_panel.py | 2 -- 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/nipanel/_convert.py b/src/nipanel/_convert.py index 61a48587..8440895e 100644 --- a/src/nipanel/_convert.py +++ b/src/nipanel/_convert.py @@ -12,6 +12,7 @@ from nipanel.converters.builtin import ( BoolConverter, BytesConverter, + DateTimeConverter, FloatConverter, IntConverter, StrConverter, @@ -37,6 +38,7 @@ FloatConverter(), IntConverter(), StrConverter(), + DateTimeConverter(), # Containers next BoolCollectionConverter(), BytesCollectionConverter(), diff --git a/src/nipanel/converters/builtin.py b/src/nipanel/converters/builtin.py index c004315c..21faef49 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 DateTimeConverter(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..317db384 100644 --- a/tests/unit/test_streamlit_panel.py +++ b/tests/unit/test_streamlit_panel.py @@ -1,5 +1,4 @@ import enum -from datetime import datetime import grpc import pytest @@ -312,7 +311,6 @@ def test___enum_type___set_value___gets_same_value( @pytest.mark.parametrize( "value_payload", [ - datetime.now(), lambda x: x + 1, [1, "string"], ["string", []], From 891ff1b106c90118b3fc3c70afb659763071fb03 Mon Sep 17 00:00:00 2001 From: Michael Johansen Date: Thu, 31 Jul 2025 10:42:50 -0500 Subject: [PATCH 2/6] Update and run the all_types example. Signed-off-by: Michael Johansen --- examples/all_types/define_types.py | 2 ++ 1 file changed, 2 insertions(+) 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, From 93f84842408238985acb0e31815d98f828e5a2a3 Mon Sep 17 00:00:00 2001 From: Michael Johansen Date: Thu, 31 Jul 2025 13:26:33 -0500 Subject: [PATCH 3/6] Add time and date controls to the all_types example. Signed-off-by: Michael Johansen --- examples/all_types/all_types_panel.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/all_types/all_types_panel.py b/examples/all_types/all_types_panel.py index 529d39b2..b96ed72b 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,13 @@ 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): + st.time_input(label=name, value=default_value, key=f"{name}_time") + st.date_input(label=name, value=default_value, key=f"{name}_date") + elif isinstance(default_value, dt.date): + st.date_input(label=name, value=default_value, key=name) + elif isinstance(default_value, dt.time): + st.time_input(label=name, value=default_value, key=name) with col3: st.write(panel.get_value(name, default_value=default_value)) From 23f6dd3b30ab640d9c94cdca02a8ca07d26594e9 Mon Sep 17 00:00:00 2001 From: Michael Johansen Date: Thu, 31 Jul 2025 13:30:05 -0500 Subject: [PATCH 4/6] Rename converter to be specific to datetime.datetime. Signed-off-by: Michael Johansen --- src/nipanel/_convert.py | 4 ++-- src/nipanel/converters/builtin.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/nipanel/_convert.py b/src/nipanel/_convert.py index 8440895e..6de5eff7 100644 --- a/src/nipanel/_convert.py +++ b/src/nipanel/_convert.py @@ -12,7 +12,7 @@ from nipanel.converters.builtin import ( BoolConverter, BytesConverter, - DateTimeConverter, + DTDateTimeConverter, FloatConverter, IntConverter, StrConverter, @@ -38,7 +38,7 @@ FloatConverter(), IntConverter(), StrConverter(), - DateTimeConverter(), + DTDateTimeConverter(), # Containers next BoolCollectionConverter(), BytesCollectionConverter(), diff --git a/src/nipanel/converters/builtin.py b/src/nipanel/converters/builtin.py index 21faef49..50c9b932 100644 --- a/src/nipanel/converters/builtin.py +++ b/src/nipanel/converters/builtin.py @@ -120,7 +120,7 @@ def to_python_value(self, protobuf_message: wrappers_pb2.StringValue) -> str: return protobuf_message.value -class DateTimeConverter(Converter[dt.datetime, timestamp_pb2.Timestamp]): +class DTDateTimeConverter(Converter[dt.datetime, timestamp_pb2.Timestamp]): """A converter for datetime.datetime types.""" @property From 7c91336798e00de0b912c7153fa9199ea2259a8d Mon Sep 17 00:00:00 2001 From: Michael Johansen Date: Thu, 31 Jul 2025 16:17:26 -0500 Subject: [PATCH 5/6] Update all_types example so that datetime keeps data synchronization. Signed-off-by: Michael Johansen --- examples/all_types/all_types_panel.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/examples/all_types/all_types_panel.py b/examples/all_types/all_types_panel.py index b96ed72b..f8c6bec2 100644 --- a/examples/all_types/all_types_panel.py +++ b/examples/all_types/all_types_panel.py @@ -37,8 +37,10 @@ elif isinstance(default_value, str): st.text_input(label=name, value=default_value, key=name) elif isinstance(default_value, dt.datetime): - st.time_input(label=name, value=default_value, key=f"{name}_time") - st.date_input(label=name, value=default_value, key=f"{name}_date") + 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) elif isinstance(default_value, dt.date): st.date_input(label=name, value=default_value, key=name) elif isinstance(default_value, dt.time): From 1f0886402354b64493ef564a5aac049936b9ef2f Mon Sep 17 00:00:00 2001 From: Michael Johansen Date: Thu, 31 Jul 2025 16:40:54 -0500 Subject: [PATCH 6/6] Remove unneeded lines from example. Add a unit test case. Signed-off-by: Michael Johansen --- examples/all_types/all_types_panel.py | 4 ---- tests/unit/test_streamlit_panel.py | 2 ++ 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/examples/all_types/all_types_panel.py b/examples/all_types/all_types_panel.py index f8c6bec2..8c84eee9 100644 --- a/examples/all_types/all_types_panel.py +++ b/examples/all_types/all_types_panel.py @@ -41,10 +41,6 @@ time = st.time_input(label="time", value=default_value) datetime = dt.datetime.combine(date, time) panel.set_value(name, datetime) - elif isinstance(default_value, dt.date): - st.date_input(label=name, value=default_value, key=name) - elif isinstance(default_value, dt.time): - st.time_input(label=name, value=default_value, key=name) with col3: st.write(panel.get_value(name, default_value=default_value)) diff --git a/tests/unit/test_streamlit_panel.py b/tests/unit/test_streamlit_panel.py index 317db384..860bacf8 100644 --- a/tests/unit/test_streamlit_panel.py +++ b/tests/unit/test_streamlit_panel.py @@ -1,3 +1,4 @@ +import datetime as dt import enum import grpc @@ -265,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(