Skip to content

Commit a87939a

Browse files
authored
Add support for builtin types for get_value and set_value (#26)
* Add tests for unopened-set, unopened-get, and scalar round trips * Add test showing an unset value raises when clients attempt to get it * Rename fixture 'grpc_channel_for_fake_panel_service' to 'fake_panel_channel' * Use grcpio's logging thread pool * Remove static storage for the fake servicer * Remove class members from the fake service
1 parent 5b1cef0 commit a87939a

File tree

11 files changed

+488
-30
lines changed

11 files changed

+488
-30
lines changed

CONTRIBUTING.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ poetry run nps lint
4343
poetry run mypy
4444
poetry run bandit -c pyproject.toml -r src/nipanel
4545
46+
# Apply safe fixes
47+
poetry run nps fix
48+
4649
# Run the tests
4750
poetry run pytest -v
4851

examples/placeholder.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,58 @@
11
"""Placeholder example for the package."""
2+
3+
import enum
4+
5+
import nipanel
6+
7+
8+
class MyIntFlags(enum.IntFlag):
9+
"""Example of an IntFlag enum."""
10+
11+
VALUE1 = 1
12+
VALUE2 = 2
13+
VALUE4 = 4
14+
15+
16+
class MyIntEnum(enum.IntEnum):
17+
"""Example of an IntEnum enum."""
18+
19+
VALUE10 = 10
20+
VALUE20 = 20
21+
VALUE30 = 30
22+
23+
24+
class MyStrEnum(str, enum.Enum):
25+
"""Example of a mixin string enum."""
26+
27+
VALUE1 = "value1"
28+
VALUE2 = "value2"
29+
VALUE3 = "value3"
30+
31+
32+
if __name__ == "__main__":
33+
my_panel = nipanel.StreamlitPanel(
34+
panel_id="placeholder",
35+
streamlit_script_uri=__file__,
36+
)
37+
38+
my_types = {
39+
"my_str": "im justa smol str",
40+
"my_int": 42,
41+
"my_float": 13.12,
42+
"my_bool": True,
43+
"my_bytes": b"robotext",
44+
"my_intflags": MyIntFlags.VALUE1 | MyIntFlags.VALUE4,
45+
"my_intenum": MyIntEnum.VALUE20,
46+
"my_strenum": MyStrEnum.VALUE3,
47+
}
48+
49+
print("Setting values")
50+
for name, value in my_types.items():
51+
print(f"{name:>15} {value}")
52+
my_panel.set_value(name, value)
53+
54+
print()
55+
print("Getting values")
56+
for name in my_types.keys():
57+
the_value = my_panel.get_value(name)
58+
print(f"{name:>15} {the_value}")

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ strict = true
6767
module = [
6868
# https://github.com/ni/hightime/issues/4 - Add type annotations
6969
"hightime.*",
70+
"grpc.framework.foundation.*",
7071
]
7172
ignore_missing_imports = true
7273

src/nipanel/_converters.py

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""Functions to convert between different data formats."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from abc import ABC, abstractmethod
7+
from typing import Any, Generic, Type, TypeVar
8+
9+
from google.protobuf import any_pb2, wrappers_pb2
10+
from google.protobuf.message import Message
11+
12+
_TPythonType = TypeVar("_TPythonType")
13+
_TProtobufType = TypeVar("_TProtobufType", bound=Message)
14+
15+
_logger = logging.getLogger(__name__)
16+
17+
18+
class Converter(Generic[_TPythonType, _TProtobufType], ABC):
19+
"""A class that defines how to convert between Python objects and protobuf Any messages."""
20+
21+
@property
22+
@abstractmethod
23+
def python_type(self) -> Type[_TPythonType]:
24+
"""The Python type that this converter handles."""
25+
26+
@property
27+
@abstractmethod
28+
def protobuf_message(self) -> Type[_TProtobufType]:
29+
"""The type-specific protobuf message for the Python type."""
30+
31+
@property
32+
def protobuf_typename(self) -> str:
33+
"""The protobuf name for the type."""
34+
return self.protobuf_message.DESCRIPTOR.full_name # type: ignore[no-any-return]
35+
36+
def to_protobuf_any(self, python_value: _TPythonType) -> any_pb2.Any:
37+
"""Convert the Python object to its type-specific message and pack it as any_pb2.Any."""
38+
message = self.to_protobuf_message(python_value)
39+
as_any = any_pb2.Any()
40+
as_any.Pack(message)
41+
return as_any
42+
43+
@abstractmethod
44+
def to_protobuf_message(self, python_value: _TPythonType) -> _TProtobufType:
45+
"""Convert the Python object to its type-specific message."""
46+
47+
def to_python(self, protobuf_value: any_pb2.Any) -> _TPythonType:
48+
"""Convert the protobuf Any message to its matching Python type."""
49+
protobuf_message = self.protobuf_message()
50+
did_unpack = protobuf_value.Unpack(protobuf_message)
51+
if not did_unpack:
52+
raise ValueError(f"Failed to unpack Any with type '{protobuf_value.TypeName()}'")
53+
return self.to_python_value(protobuf_message)
54+
55+
@abstractmethod
56+
def to_python_value(self, protobuf_message: _TProtobufType) -> _TPythonType:
57+
"""Convert the protobuf wrapper message to its matching Python type."""
58+
59+
60+
class BoolConverter(Converter[bool, wrappers_pb2.BoolValue]):
61+
"""A converter for boolean types."""
62+
63+
@property
64+
def python_type(self) -> Type[bool]:
65+
"""The Python type that this converter handles."""
66+
return bool
67+
68+
@property
69+
def protobuf_message(self) -> Type[wrappers_pb2.BoolValue]:
70+
"""The type-specific protobuf message for the Python type."""
71+
return wrappers_pb2.BoolValue
72+
73+
def to_protobuf_message(self, python_value: bool) -> wrappers_pb2.BoolValue:
74+
"""Convert the Python bool to a protobuf wrappers_pb2.BoolValue."""
75+
return self.protobuf_message(value=python_value)
76+
77+
def to_python_value(self, protobuf_value: wrappers_pb2.BoolValue) -> bool:
78+
"""Convert the protobuf message to a Python bool."""
79+
return protobuf_value.value
80+
81+
82+
class BytesConverter(Converter[bytes, wrappers_pb2.BytesValue]):
83+
"""A converter for byte string types."""
84+
85+
@property
86+
def python_type(self) -> Type[bytes]:
87+
"""The Python type that this converter handles."""
88+
return bytes
89+
90+
@property
91+
def protobuf_message(self) -> Type[wrappers_pb2.BytesValue]:
92+
"""The type-specific protobuf message for the Python type."""
93+
return wrappers_pb2.BytesValue
94+
95+
def to_protobuf_message(self, python_value: bytes) -> wrappers_pb2.BytesValue:
96+
"""Convert the Python bytes string to a protobuf wrappers_pb2.BytesValue."""
97+
return self.protobuf_message(value=python_value)
98+
99+
def to_python_value(self, protobuf_value: wrappers_pb2.BytesValue) -> bytes:
100+
"""Convert the protobuf message to a Python bytes string."""
101+
return protobuf_value.value
102+
103+
104+
class FloatConverter(Converter[float, wrappers_pb2.DoubleValue]):
105+
"""A converter for floating point types."""
106+
107+
@property
108+
def python_type(self) -> Type[float]:
109+
"""The Python type that this converter handles."""
110+
return float
111+
112+
@property
113+
def protobuf_message(self) -> Type[wrappers_pb2.DoubleValue]:
114+
"""The type-specific protobuf message for the Python type."""
115+
return wrappers_pb2.DoubleValue
116+
117+
def to_protobuf_message(self, python_value: float) -> wrappers_pb2.DoubleValue:
118+
"""Convert the Python float to a protobuf wrappers_pb2.DoubleValue."""
119+
return self.protobuf_message(value=python_value)
120+
121+
def to_python_value(self, protobuf_value: wrappers_pb2.DoubleValue) -> float:
122+
"""Convert the protobuf message to a Python float."""
123+
return protobuf_value.value
124+
125+
126+
class IntConverter(Converter[int, wrappers_pb2.Int64Value]):
127+
"""A converter for integer types."""
128+
129+
@property
130+
def python_type(self) -> Type[int]:
131+
"""The Python type that this converter handles."""
132+
return int
133+
134+
@property
135+
def protobuf_message(self) -> Type[wrappers_pb2.Int64Value]:
136+
"""The type-specific protobuf message for the Python type."""
137+
return wrappers_pb2.Int64Value
138+
139+
def to_protobuf_message(self, python_value: int) -> wrappers_pb2.Int64Value:
140+
"""Convert the Python int to a protobuf wrappers_pb2.Int64Value."""
141+
return self.protobuf_message(value=python_value)
142+
143+
def to_python_value(self, protobuf_value: wrappers_pb2.Int64Value) -> int:
144+
"""Convert the protobuf message to a Python int."""
145+
return protobuf_value.value
146+
147+
148+
class StrConverter(Converter[str, wrappers_pb2.StringValue]):
149+
"""A converter for text string types."""
150+
151+
@property
152+
def python_type(self) -> Type[str]:
153+
"""The Python type that this converter handles."""
154+
return str
155+
156+
@property
157+
def protobuf_message(self) -> Type[wrappers_pb2.StringValue]:
158+
"""The type-specific protobuf message for the Python type."""
159+
return wrappers_pb2.StringValue
160+
161+
def to_protobuf_message(self, python_value: str) -> wrappers_pb2.StringValue:
162+
"""Convert the Python str to a protobuf wrappers_pb2.StringValue."""
163+
return self.protobuf_message(value=python_value)
164+
165+
def to_python_value(self, protobuf_value: wrappers_pb2.StringValue) -> str:
166+
"""Convert the protobuf message to a Python string."""
167+
return protobuf_value.value
168+
169+
170+
# FFV -- consider adding a RegisterConverter mechanism
171+
_CONVERTIBLE_TYPES: list[Converter[Any, Any]] = [
172+
BoolConverter(),
173+
BytesConverter(),
174+
FloatConverter(),
175+
IntConverter(),
176+
StrConverter(),
177+
]
178+
179+
_CONVERTER_FOR_PYTHON_TYPE = {entry.python_type: entry for entry in _CONVERTIBLE_TYPES}
180+
_CONVERTER_FOR_GRPC_TYPE = {entry.protobuf_typename: entry for entry in _CONVERTIBLE_TYPES}
181+
_SUPPORTED_PYTHON_TYPES = _CONVERTER_FOR_PYTHON_TYPE.keys()
182+
183+
184+
def to_any(python_value: object) -> any_pb2.Any:
185+
"""Convert a Python object to a protobuf Any."""
186+
underlying_parents = type(python_value).mro() # This covers enum.IntEnum and similar
187+
188+
best_matching_type = next(
189+
(parent for parent in underlying_parents if parent in _SUPPORTED_PYTHON_TYPES), None
190+
)
191+
if not best_matching_type:
192+
raise TypeError(
193+
f"Unsupported type: {type(python_value)} with parents {underlying_parents}. Supported types are: {_SUPPORTED_PYTHON_TYPES}"
194+
)
195+
_logger.debug(f"Best matching type for '{repr(python_value)}' resolved to {best_matching_type}")
196+
197+
converter = _CONVERTER_FOR_PYTHON_TYPE[best_matching_type]
198+
return converter.to_protobuf_any(python_value)
199+
200+
201+
def from_any(protobuf_any: any_pb2.Any) -> object:
202+
"""Convert a protobuf Any to a Python object."""
203+
if not isinstance(protobuf_any, any_pb2.Any):
204+
raise ValueError(f"Unexpected type: {type(protobuf_any)}")
205+
206+
underlying_typename = protobuf_any.TypeName()
207+
_logger.debug(f"Unpacking type '{underlying_typename}'")
208+
209+
converter = _CONVERTER_FOR_GRPC_TYPE[underlying_typename]
210+
return converter.to_python(protobuf_any)

src/nipanel/_panel.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,7 @@ def get_value(self, value_id: str) -> object:
6363
Returns:
6464
The value
6565
"""
66-
# TODO: AB#3095681 - get the Any from _client.get_value and convert it to the correct type
67-
return "placeholder value"
66+
return self._panel_client.get_value(self._panel_id, value_id)
6867

6968
def set_value(self, value_id: str, value: object) -> None:
7069
"""Set the value for a control on the panel.
@@ -73,5 +72,4 @@ def set_value(self, value_id: str, value: object) -> None:
7372
value_id: The id of the value
7473
value: The value
7574
"""
76-
# TODO: AB#3095681 - Convert the value to an Any and pass it to _client.set_value
77-
pass
75+
self._panel_client.set_value(self._panel_id, value_id, value)

src/nipanel/_panel_client.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,21 @@
77
from typing import Callable, TypeVar
88

99
import grpc
10-
from ni.pythonpanel.v1.python_panel_service_pb2 import OpenPanelRequest
10+
from ni.pythonpanel.v1.python_panel_service_pb2 import (
11+
GetValueRequest,
12+
OpenPanelRequest,
13+
SetValueRequest,
14+
)
1115
from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub
1216
from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient
1317
from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool
1418
from typing_extensions import ParamSpec
1519

20+
from nipanel._converters import (
21+
from_any,
22+
to_any,
23+
)
24+
1625
_P = ParamSpec("_P")
1726
_T = TypeVar("_T")
1827

@@ -53,6 +62,33 @@ def open_panel(self, panel_id: str, panel_uri: str) -> None:
5362
open_panel_request = OpenPanelRequest(panel_id=panel_id, panel_uri=panel_uri)
5463
self._invoke_with_retry(self._get_stub().OpenPanel, open_panel_request)
5564

65+
def set_value(self, panel_id: str, value_id: str, value: object) -> None:
66+
"""Set the value for the control with value_id.
67+
68+
Args:
69+
panel_id: The ID of the panel.
70+
value_id: The ID of the control.
71+
value: The value to set.
72+
"""
73+
new_any = to_any(value)
74+
set_value_request = SetValueRequest(panel_id=panel_id, value_id=value_id, value=new_any)
75+
self._invoke_with_retry(self._get_stub().SetValue, set_value_request)
76+
77+
def get_value(self, panel_id: str, value_id: str) -> object:
78+
"""Get the value for the control with value_id.
79+
80+
Args:
81+
panel_id: The ID of the panel.
82+
value_id: The ID of the control.
83+
84+
Returns:
85+
The value.
86+
"""
87+
get_value_request = GetValueRequest(panel_id=panel_id, value_id=value_id)
88+
response = self._invoke_with_retry(self._get_stub().GetValue, get_value_request)
89+
the_value = from_any(response.value)
90+
return the_value
91+
5692
def _get_stub(self) -> PythonPanelServiceStub:
5793
if self._stub is None:
5894
if self._grpc_channel is not None:

0 commit comments

Comments
 (0)