Skip to content
Merged
Show file tree
Hide file tree
Changes from 40 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
6643e6b
First draft of builtin type marshalling
jfriedri-ni May 13, 2025
8d97456
Use a brute-force explicit helper for type-hinting the convertible na…
jfriedri-ni May 14, 2025
02c1cdd
Use the StreamlitPanel as the main entrypoint
jfriedri-ni May 14, 2025
ec9e760
Add tests for unopened-set, unopened-get, and scalar round trips
jfriedri-ni May 14, 2025
4407668
Remove unused import
jfriedri-ni May 14, 2025
86c22aa
Add test showing an unset value raises when clients attempt to get it
jfriedri-ni May 14, 2025
b3e504a
Merge remote-tracking branch 'origin/main' into users/jfriedri-ni/add…
jfriedri-ni May 14, 2025
b63ea73
Add type hints that are compatible with Python 3.9
jfriedri-ni May 15, 2025
a60c17f
Do not add type hints when the analyzer can infer it
jfriedri-ni May 15, 2025
b3f1314
Parameterize the scalar value test
jfriedri-ni May 15, 2025
4d73ca6
Add roundtrip tests for IntFlag, IntEnum, StrEnum
jfriedri-ni May 15, 2025
518dd2f
Add tests for mixin-enums and bare-enums
jfriedri-ni May 15, 2025
345e89a
Shorten typename imports
jfriedri-ni May 15, 2025
3a02372
Rename fixture 'grpc_channel_for_fake_panel_service' to 'fake_panel_c…
jfriedri-ni May 15, 2025
ca9b8a3
Use grcpio's logging thread pool
jfriedri-ni May 15, 2025
91ee96b
Remove static storage for the fake servicer
jfriedri-ni May 15, 2025
63296e1
Remove class members from the fake service
jfriedri-ni May 15, 2025
5969e92
Simplify class and storage for ConvertibleType
jfriedri-ni May 15, 2025
3fb2b9b
Use pyproject.toml to suppress type checker
jfriedri-ni May 15, 2025
62f808c
Clarify docstring
jfriedri-ni May 15, 2025
9a5ae3c
Do not use 'as' for imports because it confuses tooling
jfriedri-ni May 15, 2025
eabcb0f
Make types compatible with Python 3.9 and 3.10
jfriedri-ni May 15, 2025
6457e41
Remove redundant namespacing and front-load building the type convers…
jfriedri-ni May 15, 2025
fde87f6
Use the Python types rather than typenames for to_any
jfriedri-ni May 15, 2025
905e315
Shortest path to custom initializers
jfriedri-ni May 16, 2025
b73b7b0
Use a Protocol to allow converters to have custom conversions
jfriedri-ni May 16, 2025
e07a2c0
Apply formatter
jfriedri-ni May 16, 2025
3285505
Make protobuf_message return a Message so the default implementation …
jfriedri-ni May 16, 2025
e9fe18c
Simple naive type-fix
jfriedri-ni May 19, 2025
dc0c552
Refactor converters as an ABC family
jfriedri-ni May 20, 2025
4f3a208
Converting builtins with a shared function, still mypy errors
jfriedri-ni May 20, 2025
9115100
Do not share code between the builtin converters because type hinting…
jfriedri-ni May 20, 2025
6d8318b
Address the linters
jfriedri-ni May 20, 2025
3313691
Follow naming convention for covariant typevars
jfriedri-ni May 20, 2025
c1324ae
Rely on set_value throwing for test___unopened_panel___set_value___se…
jfriedri-ni May 20, 2025
da0a6d7
Merge remote-tracking branch 'origin/main' into users/jfriedri-ni/add…
jfriedri-ni May 20, 2025
d5d4c51
converters: Refactor message->Any conversion
bkeryan May 20, 2025
ae54c82
converters: Refactor to_python any handling
bkeryan May 20, 2025
6ff26ea
Clarify some docstrings
jfriedri-ni May 21, 2025
072a3ac
Use object in the to_any() and from_any() signatures
jfriedri-ni May 21, 2025
83e6363
converters: Use type variables more consistently
bkeryan May 21, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ poetry run nps lint
poetry run mypy
poetry run bandit -c pyproject.toml -r src/nipanel

# Apply safe fixes
poetry run nps fix

# Run the tests
poetry run pytest -v

Expand Down
57 changes: 57 additions & 0 deletions examples/placeholder.py
Original file line number Diff line number Diff line change
@@ -1 +1,58 @@
"""Placeholder example for the package."""

import enum

import nipanel


class MyIntFlags(enum.IntFlag):
"""Example of an IntFlag enum."""

VALUE1 = 1
VALUE2 = 2
VALUE4 = 4


class MyIntEnum(enum.IntEnum):
"""Example of an IntEnum enum."""

VALUE10 = 10
VALUE20 = 20
VALUE30 = 30


class MyStrEnum(str, enum.Enum):
"""Example of a mixin string enum."""

VALUE1 = "value1"
VALUE2 = "value2"
VALUE3 = "value3"


if __name__ == "__main__":
my_panel = nipanel.StreamlitPanel(
panel_id="placeholder",
streamlit_script_uri=__file__,
)

my_types = {
"my_str": "im justa smol str",
"my_int": 42,
"my_float": 13.12,
"my_bool": True,
"my_bytes": b"robotext",
"my_intflags": MyIntFlags.VALUE1 | MyIntFlags.VALUE4,
"my_intenum": MyIntEnum.VALUE20,
"my_strenum": MyStrEnum.VALUE3,
}

print("Setting values")
for name, value in my_types.items():
print(f"{name:>15} {value}")
my_panel.set_value(name, value)

print()
print("Getting values")
for name in my_types.keys():
the_value = my_panel.get_value(name)
print(f"{name:>15} {the_value}")
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ strict = true
module = [
# https://github.com/ni/hightime/issues/4 - Add type annotations
"hightime.*",
"grpc.framework.foundation.*",
]
ignore_missing_imports = true

Expand Down
210 changes: 210 additions & 0 deletions src/nipanel/_converters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
"""Functions to convert between different data formats."""

from __future__ import annotations

import logging
from abc import ABC, abstractmethod
from typing import Any, Generic, Type, TypeVar

from google.protobuf import any_pb2, wrappers_pb2
from google.protobuf.message import Message

_TPythonType = TypeVar("_TPythonType")
_TProtobufType = TypeVar("_TProtobufType", bound=Message)

_logger = logging.getLogger(__name__)


class Converter(Generic[_TPythonType, _TProtobufType], ABC):
"""A class that defines how to convert between Python objects and protobuf Any messages."""

@property
@abstractmethod
def python_type(self) -> Type[_TPythonType]:
"""The Python type that this converter handles."""

@property
@abstractmethod
def protobuf_message(self) -> Type[_TProtobufType]:
"""The type-specific protobuf message for the Python type."""

@property
def protobuf_typename(self) -> str:
"""The protobuf name for the type."""
return self.protobuf_message.DESCRIPTOR.full_name # type: ignore[no-any-return]

def to_protobuf_any(self, python_value: Any) -> any_pb2.Any:
"""Convert the Python object to its type-specific message and pack it as any_pb2.Any."""
message = self.to_protobuf_message(python_value)
as_any = any_pb2.Any()
as_any.Pack(message)
return as_any

@abstractmethod
def to_protobuf_message(self, python_value: Any) -> Message:
"""Convert the Python object to its type-specific message."""

def to_python(self, protobuf_value: any_pb2.Any) -> _TPythonType:
"""Convert the protobuf Any message to its matching Python type."""
protobuf_message = self.protobuf_message()
did_unpack = protobuf_value.Unpack(protobuf_message)
if not did_unpack:
raise ValueError(f"Failed to unpack Any with type '{protobuf_value.TypeName()}'")
return self.to_python_value(protobuf_message)

@abstractmethod
def to_python_value(self, protobuf_message: _TProtobufType) -> _TPythonType:
"""Convert the protobuf wrapper message to its matching Python type."""


class BoolConverter(Converter[bool, wrappers_pb2.BoolValue]):
"""A converter for boolean types."""

@property
def python_type(self) -> Type[bool]:
"""The Python type that this converter handles."""
return bool

@property
def protobuf_message(self) -> Type[wrappers_pb2.BoolValue]:
"""The type-specific protobuf message for the Python type."""
return wrappers_pb2.BoolValue

def to_protobuf_message(self, python_value: bool) -> Message:
"""Convert the Python bool to a protobuf wrappers_pb2.BoolValue."""
return self.protobuf_message(value=python_value)

def to_python_value(self, protobuf_value: wrappers_pb2.BoolValue) -> bool:
"""Convert the protobuf message to a Python bool."""
return protobuf_value.value


class BytesConverter(Converter[bytes, wrappers_pb2.BytesValue]):
"""A converter for byte string types."""

@property
def python_type(self) -> Type[bytes]:
"""The Python type that this converter handles."""
return bytes

@property
def protobuf_message(self) -> Type[wrappers_pb2.BytesValue]:
"""The type-specific protobuf message for the Python type."""
return wrappers_pb2.BytesValue

def to_protobuf_message(self, python_value: bytes) -> Message:
"""Convert the Python bytes string to a protobuf wrappers_pb2.BytesValue."""
return self.protobuf_message(value=python_value)

def to_python_value(self, protobuf_value: wrappers_pb2.BytesValue) -> bytes:
"""Convert the protobuf message to a Python bytes string."""
return protobuf_value.value


class FloatConverter(Converter[float, wrappers_pb2.DoubleValue]):
"""A converter for floating point types."""

@property
def python_type(self) -> Type[float]:
"""The Python type that this converter handles."""
return float

@property
def protobuf_message(self) -> Type[wrappers_pb2.DoubleValue]:
"""The type-specific protobuf message for the Python type."""
return wrappers_pb2.DoubleValue

def to_protobuf_message(self, python_value: float) -> Message:
"""Convert the Python float to a protobuf wrappers_pb2.DoubleValue."""
return self.protobuf_message(value=python_value)

def to_python_value(self, protobuf_value: wrappers_pb2.DoubleValue) -> float:
"""Convert the protobuf message to a Python float."""
return protobuf_value.value


class IntConverter(Converter[int, wrappers_pb2.Int64Value]):
"""A converter for integer types."""

@property
def python_type(self) -> Type[int]:
"""The Python type that this converter handles."""
return int

@property
def protobuf_message(self) -> Type[wrappers_pb2.Int64Value]:
"""The type-specific protobuf message for the Python type."""
return wrappers_pb2.Int64Value

def to_protobuf_message(self, python_value: int) -> Message:
"""Convert the Python int to a protobuf wrappers_pb2.Int64Value."""
return self.protobuf_message(value=python_value)

def to_python_value(self, protobuf_value: wrappers_pb2.Int64Value) -> int:
"""Convert the protobuf message to a Python int."""
return protobuf_value.value


class StrConverter(Converter[str, wrappers_pb2.StringValue]):
"""A converter for text string types."""

@property
def python_type(self) -> Type[str]:
"""The Python type that this converter handles."""
return str

@property
def protobuf_message(self) -> Type[wrappers_pb2.StringValue]:
"""The type-specific protobuf message for the Python type."""
return wrappers_pb2.StringValue

def to_protobuf_message(self, python_value: str) -> Message:
"""Convert the Python str to a protobuf wrappers_pb2.StringValue."""
return self.protobuf_message(value=python_value)

def to_python_value(self, protobuf_value: wrappers_pb2.StringValue) -> str:
"""Convert the protobuf message to a Python string."""
return protobuf_value.value


# FFV -- consider adding a RegisterConverter mechanism
_CONVERTIBLE_TYPES: list[Converter[Any, Any]] = [
BoolConverter(),
BytesConverter(),
FloatConverter(),
IntConverter(),
StrConverter(),
]

_CONVERTER_FOR_PYTHON_TYPE = {entry.python_type: entry for entry in _CONVERTIBLE_TYPES}
_CONVERTER_FOR_GRPC_TYPE = {entry.protobuf_typename: entry for entry in _CONVERTIBLE_TYPES}
_SUPPORTED_PYTHON_TYPES = _CONVERTER_FOR_PYTHON_TYPE.keys()


def to_any(python_value: object) -> any_pb2.Any:
"""Convert a Python object to a protobuf Any."""
underlying_parents = type(python_value).mro() # This covers enum.IntEnum and similar

best_matching_type = next(
(parent for parent in underlying_parents if parent in _SUPPORTED_PYTHON_TYPES), None
)
if not best_matching_type:
raise TypeError(
f"Unsupported type: {type(python_value)} with parents {underlying_parents}. Supported types are: {_SUPPORTED_PYTHON_TYPES}"
)
_logger.debug(f"Best matching type for '{repr(python_value)}' resolved to {best_matching_type}")

converter = _CONVERTER_FOR_PYTHON_TYPE[best_matching_type]
return converter.to_protobuf_any(python_value)


def from_any(protobuf_any: any_pb2.Any) -> object:
"""Convert a protobuf Any to a Python object."""
if not isinstance(protobuf_any, any_pb2.Any):
raise ValueError(f"Unexpected type: {type(protobuf_any)}")

underlying_typename = protobuf_any.TypeName()
_logger.debug(f"Unpacking type '{underlying_typename}'")

converter = _CONVERTER_FOR_GRPC_TYPE[underlying_typename]
return converter.to_python(protobuf_any)
6 changes: 2 additions & 4 deletions src/nipanel/_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,7 @@ def get_value(self, value_id: str) -> object:
Returns:
The value
"""
# TODO: AB#3095681 - get the Any from _client.get_value and convert it to the correct type
return "placeholder value"
return self._panel_client.get_value(self._panel_id, value_id)

def set_value(self, value_id: str, value: object) -> None:
"""Set the value for a control on the panel.
Expand All @@ -73,5 +72,4 @@ def set_value(self, value_id: str, value: object) -> None:
value_id: The id of the value
value: The value
"""
# TODO: AB#3095681 - Convert the value to an Any and pass it to _client.set_value
pass
self._panel_client.set_value(self._panel_id, value_id, value)
38 changes: 37 additions & 1 deletion src/nipanel/_panel_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,21 @@
from typing import Callable, TypeVar

import grpc
from ni.pythonpanel.v1.python_panel_service_pb2 import OpenPanelRequest
from ni.pythonpanel.v1.python_panel_service_pb2 import (
GetValueRequest,
OpenPanelRequest,
SetValueRequest,
)
from ni.pythonpanel.v1.python_panel_service_pb2_grpc import PythonPanelServiceStub
from ni_measurement_plugin_sdk_service.discovery import DiscoveryClient
from ni_measurement_plugin_sdk_service.grpc.channelpool import GrpcChannelPool
from typing_extensions import ParamSpec

from nipanel._converters import (
from_any,
to_any,
)

_P = ParamSpec("_P")
_T = TypeVar("_T")

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

def set_value(self, panel_id: str, value_id: str, value: object) -> None:
"""Set the value for the control with value_id.

Args:
panel_id: The ID of the panel.
value_id: The ID of the control.
value: The value to set.
"""
new_any = to_any(value)
set_value_request = SetValueRequest(panel_id=panel_id, value_id=value_id, value=new_any)
self._invoke_with_retry(self._get_stub().SetValue, set_value_request)

def get_value(self, panel_id: str, value_id: str) -> object:
"""Get the value for the control with value_id.

Args:
panel_id: The ID of the panel.
value_id: The ID of the control.

Returns:
The value.
"""
get_value_request = GetValueRequest(panel_id=panel_id, value_id=value_id)
response = self._invoke_with_retry(self._get_stub().GetValue, get_value_request)
the_value = from_any(response.value)
return the_value

def _get_stub(self) -> PythonPanelServiceStub:
if self._stub is None:
if self._grpc_channel is not None:
Expand Down
Loading