Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
25 changes: 13 additions & 12 deletions src/nipanel/_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,25 @@
from __future__ import annotations

import logging
import typing
from typing import Callable, NamedTuple, Union

import google.protobuf.any_pb2
import google.protobuf.wrappers_pb2
from google.protobuf import wrappers_pb2
from typing_extensions import TypeAlias

_logger = logging.getLogger(__name__)


_builtin_protobuf_type = (
google.protobuf.wrappers_pb2.BoolValue
| google.protobuf.wrappers_pb2.BytesValue
| google.protobuf.wrappers_pb2.DoubleValue
| google.protobuf.wrappers_pb2.Int64Value
| google.protobuf.wrappers_pb2.StringValue
)
_builtin_protobuf_type: TypeAlias = Union[
wrappers_pb2.BoolValue,
wrappers_pb2.BytesValue,
wrappers_pb2.DoubleValue,
wrappers_pb2.Int64Value,
wrappers_pb2.StringValue,
]


class ConvertibleType(typing.NamedTuple):
class ConvertibleType(NamedTuple):
"""A Python type that can be converted to and from a protobuf Any."""

name: str
Expand All @@ -32,11 +33,11 @@ class ConvertibleType(typing.NamedTuple):
protobuf_typename: str
"""The protobuf name for the type."""

protobuf_initializer: typing.Callable[..., _builtin_protobuf_type]
protobuf_initializer: Callable[..., _builtin_protobuf_type]
"""A callable that can be used to create an instance of the protobuf type."""


_CONVERTIBLE_TYPES: dict[str, ConvertibleType] = {
_CONVERTIBLE_TYPES = {
"bool": ConvertibleType(
name="Boolean",
python_typename=bool.__name__,
Expand Down
11 changes: 5 additions & 6 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Fixtures for testing."""

from collections.abc import Generator
from concurrent import futures

import grpc
import pytest
from grpc.framework.foundation import logging_pool # type: ignore # types-grpcio does not cover this yet
from ni.pythonpanel.v1.python_panel_service_pb2_grpc import (
PythonPanelServiceStub,
)
Expand All @@ -15,15 +15,15 @@
@pytest.fixture
def fake_python_panel_service() -> Generator[FakePythonPanelService]:
"""Fixture to create a FakePythonPanelServicer for testing."""
with futures.ThreadPoolExecutor(max_workers=10) as thread_pool:
with logging_pool.pool(max_workers=10) as thread_pool:
service = FakePythonPanelService()
service.start(thread_pool)
yield service
service.stop()


@pytest.fixture
def grpc_channel_for_fake_panel_service(
def fake_panel_channel(
fake_python_panel_service: FakePythonPanelService,
) -> Generator[grpc.Channel]:
"""Fixture to get a channel to the FakePythonPanelService."""
Expand All @@ -35,8 +35,7 @@ def grpc_channel_for_fake_panel_service(

@pytest.fixture
def python_panel_service_stub(
grpc_channel_for_fake_panel_service: grpc.Channel,
fake_panel_channel: grpc.Channel,
) -> Generator[PythonPanelServiceStub]:
"""Fixture to get a PythonPanelServiceStub, attached to a FakePythonPanelService."""
channel = grpc_channel_for_fake_panel_service
yield PythonPanelServiceStub(channel)
yield PythonPanelServiceStub(fake_panel_channel)
59 changes: 59 additions & 0 deletions tests/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Types that support conversion to and from protobuf."""

import enum


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 MixinIntEnum(int, enum.Enum):
"""Example of an IntEnum using a mixin."""

VALUE11 = 11
VALUE22 = 22
VALUE33 = 33


class MyStrEnum(enum.StrEnum):
"""Example of a StrEnum enum."""

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


class MixinStrEnum(str, enum.Enum):
"""Example of a StrEnum using a mixin."""

VALUE11 = "value11"
VALUE22 = "value22"
VALUE33 = "value33"


class MyEnum(enum.Enum):
"""Example of a simple enum."""

VALUE100 = 100
VALUE200 = 200
VALUE300 = 300


class MyFlags(enum.Flag):
"""Example of a simple flag."""

VALUE8 = 8
VALUE16 = 16
VALUE32 = 32
83 changes: 48 additions & 35 deletions tests/unit/test_streamlit_panel.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import grpc
import pytest

import tests.types as test_types
from nipanel._streamlit_panel import StreamlitPanel
from tests.utils._fake_python_panel_service import FakePythonPanelService

Expand All @@ -12,10 +13,9 @@ def test___panel___has_panel_id_and_panel_uri() -> None:


def test___opened_panel___set_value___gets_same_value(
grpc_channel_for_fake_panel_service: grpc.Channel,
fake_panel_channel: grpc.Channel,
) -> None:
channel = grpc_channel_for_fake_panel_service
panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=channel)
panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel)
panel.open_panel()

value_id = "test_id"
Expand All @@ -27,14 +27,13 @@ def test___opened_panel___set_value___gets_same_value(

def test___first_open_panel_fails___open_panel___gets_value(
fake_python_panel_service: FakePythonPanelService,
grpc_channel_for_fake_panel_service: grpc.Channel,
fake_panel_channel: grpc.Channel,
) -> None:
"""Test that panel.open_panel() will automatically retry once."""
channel = grpc_channel_for_fake_panel_service
service = fake_python_panel_service
# Simulate a failure on the first attempt
service.servicer.fail_next_open_panel()
panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=channel)
panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel)

panel.open_panel()

Expand All @@ -45,11 +44,10 @@ def test___first_open_panel_fails___open_panel___gets_value(


def test___unopened_panel___set_value___sets_value(
grpc_channel_for_fake_panel_service: grpc.Channel,
fake_panel_channel: grpc.Channel,
) -> None:
"""Test that set_value() succeeds before the user opens the panel."""
channel = grpc_channel_for_fake_panel_service
panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=channel)
panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel)

value_id = "test_id"
string_value = "test_value"
Expand All @@ -59,23 +57,21 @@ def test___unopened_panel___set_value___sets_value(


def test___unopened_panel___get_unset_value___raises_exception(
grpc_channel_for_fake_panel_service: grpc.Channel,
fake_panel_channel: grpc.Channel,
) -> None:
"""Test that get_value() raises an exception for an unset value."""
channel = grpc_channel_for_fake_panel_service
panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=channel)
panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel)

value_id = "test_id"
with pytest.raises(grpc.RpcError):
panel.get_value(value_id)


def test___unopened_panel___get_set_value___gets_value(
grpc_channel_for_fake_panel_service: grpc.Channel,
fake_panel_channel: grpc.Channel,
) -> None:
"""Test that get_value() succeeds for a set value before the user opens the panel."""
channel = grpc_channel_for_fake_panel_service
panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=channel)
panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel)

value_id = "test_id"
string_value = "test_value"
Expand All @@ -84,31 +80,48 @@ def test___unopened_panel___get_set_value___gets_value(
assert panel.get_value(value_id) == string_value


def test___builtin_scalar_types___set_value___gets_same_value(
grpc_channel_for_fake_panel_service: grpc.Channel,
@pytest.mark.parametrize(
"value_payload",
[
"firstname bunchanumbers",
42,
3.14,
True,
b"robotext",
test_types.MyIntFlags.VALUE1 | test_types.MyIntFlags.VALUE4,
test_types.MyIntEnum.VALUE20,
test_types.MyStrEnum.VALUE3,
test_types.MixinIntEnum.VALUE33,
test_types.MixinStrEnum.VALUE11,
],
)
def test___builtin_scalar_type___set_value___gets_same_value(
fake_panel_channel: grpc.Channel,
value_payload: object,
) -> None:
"""Test that set_value() and get_value() work for builtin scalar types."""
channel = grpc_channel_for_fake_panel_service
panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=channel)
panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel)

value_id = "test_id"
string_value = "test_value"
int_value = 42
float_value = 3.14
bool_value = True
bytes_value = b"robotext"

panel.set_value(value_id, string_value)
assert panel.get_value(value_id) == string_value
panel.set_value(value_id, value_payload)

panel.set_value(value_id, int_value)
assert panel.get_value(value_id) == int_value
assert panel.get_value(value_id) == value_payload

panel.set_value(value_id, float_value)
assert panel.get_value(value_id) == float_value

panel.set_value(value_id, bool_value)
assert panel.get_value(value_id) == bool_value
@pytest.mark.parametrize(
"value_payload",
[
test_types.MyEnum.VALUE300,
test_types.MyFlags.VALUE8 | test_types.MyFlags.VALUE16,
],
)
def test___unsupported_type___set_value___raises(
fake_panel_channel: grpc.Channel,
value_payload: object,
) -> None:
"""Test that set_value() raises for unsupported types."""
panel = StreamlitPanel("my_panel", "path/to/script", grpc_channel=fake_panel_channel)

panel.set_value(value_id, bytes_value)
assert panel.get_value(value_id) == bytes_value
value_id = "test_id"
with pytest.raises(TypeError):
panel.set_value(value_id, value_payload)
6 changes: 4 additions & 2 deletions tests/utils/_fake_python_panel_servicer.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
class FakePythonPanelServicer(PythonPanelServiceServicer):
"""Fake implementation of the PythonPanelServicer for testing."""

_values = {"test_value": any_pb2.Any()}
_fail_next_open_panel = False
def __init__(self) -> None:
"""Initialize the fake PythonPanelServicer."""
self._values = {"test_value": any_pb2.Any()}
self._fail_next_open_panel = False

def OpenPanel(self, request: OpenPanelRequest, context: Any) -> OpenPanelResponse: # noqa: N802
"""Trivial implementation for testing."""
Expand Down
Loading