Skip to content

Commit 3cabf0c

Browse files
mjohanse-emrMichael Johansen
andauthored
Add a converter to/from Double2DArray. (#91)
* Add a converter to/from Double2DArray. * Fix some variable names and remove a print. Signed-off-by: Michael Johansen <[email protected]> * Add set and frozenset tests. Signed-off-by: Michael Johansen <[email protected]> * Update the all types example. Signed-off-by: Michael Johansen <[email protected]> --------- Signed-off-by: Michael Johansen <[email protected]> Co-authored-by: Michael Johansen <[email protected]>
1 parent 0d447d9 commit 3cabf0c

File tree

5 files changed

+256
-15
lines changed

5 files changed

+256
-15
lines changed

examples/all_types/define_types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,9 @@ class MyStrEnum(str, enum.Enum):
5959
# NI types
6060
"nitypes_Scalar": Scalar(42, "m"),
6161
"nitypes_AnalogWaveform": AnalogWaveform.from_array_1d(np.array([1.0, 2.0, 3.0])),
62+
# supported 2D collections
63+
"list_list_float": [[1.0, 2.0], [3.0, 4.0]],
64+
"tuple_tuple_float": ((1.0, 2.0), (3.0, 4.0)),
65+
"set_list_float": set([(1.0, 2.0), (3.0, 4.0)]),
66+
"frozenset_frozenset_float": frozenset([frozenset([1.0, 2.0]), frozenset([3.0, 4.0])]),
6267
}

src/nipanel/_convert.py

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
IntCollectionConverter,
2222
StrCollectionConverter,
2323
)
24-
from nipanel.converters.protobuf_types import DoubleAnalogWaveformConverter, ScalarConverter
24+
from nipanel.converters.protobuf_types import (
25+
Double2DArrayConverter,
26+
DoubleAnalogWaveformConverter,
27+
ScalarConverter,
28+
)
2529

2630
_logger = logging.getLogger(__name__)
2731

@@ -40,6 +44,7 @@
4044
IntCollectionConverter(),
4145
StrCollectionConverter(),
4246
# Protobuf Types
47+
Double2DArrayConverter(),
4348
DoubleAnalogWaveformConverter(),
4449
ScalarConverter(),
4550
]
@@ -66,25 +71,39 @@ def to_any(python_value: object) -> any_pb2.Any:
6671
def _get_best_matching_type(python_value: object) -> str:
6772
underlying_parents = type(python_value).mro() # This covers enum.IntEnum and similar
6873

69-
container_type = None
70-
value_is_collection = _CONVERTIBLE_COLLECTION_TYPES.intersection(underlying_parents)
71-
if value_is_collection:
74+
container_types = []
75+
value_is_collection = any(_CONVERTIBLE_COLLECTION_TYPES.intersection(underlying_parents))
76+
# Variable to use when traversing down through collection types.
77+
working_python_value = python_value
78+
while value_is_collection:
7279
# Assume Sized -- Generators not supported, callers must use list(), set(), ... as desired
73-
if not isinstance(python_value, Collection):
80+
if not isinstance(working_python_value, Collection):
7481
raise TypeError()
75-
if len(python_value) == 0:
82+
if len(working_python_value) == 0:
7683
underlying_parents = type(None).mro()
84+
value_is_collection = False
7785
else:
7886
# Assume homogenous -- collections of mixed-types not supported
79-
visitor = iter(python_value)
80-
first_value = next(visitor)
81-
underlying_parents = type(first_value).mro()
82-
container_type = Collection
87+
visitor = iter(working_python_value)
88+
89+
# Store off the first element. If it's a container, we'll need it in the next while
90+
# loop iteration.
91+
working_python_value = next(visitor)
92+
underlying_parents = type(working_python_value).mro()
93+
94+
# If this element is a collection, we want to continue traversing. Once we find a
95+
# non-collection, underlying_parents will refer to the candidates for the non-
96+
# collection type.
97+
value_is_collection = any(
98+
_CONVERTIBLE_COLLECTION_TYPES.intersection(underlying_parents)
99+
)
100+
container_types.append(Collection.__name__)
83101

84102
best_matching_type = None
85103
candidates = [parent.__name__ for parent in underlying_parents]
86104
for candidate in candidates:
87-
python_typename = f"{container_type.__name__}.{candidate}" if container_type else candidate
105+
containers_str = ".".join(container_types)
106+
python_typename = f"{containers_str}.{candidate}" if containers_str else candidate
88107
if python_typename not in _SUPPORTED_PYTHON_TYPES:
89108
continue
90109
best_matching_type = python_typename
@@ -93,7 +112,8 @@ def _get_best_matching_type(python_value: object) -> str:
93112
if not best_matching_type:
94113
payload_type = underlying_parents[0]
95114
raise TypeError(
96-
f"Unsupported type: ({container_type}, {payload_type}) with parents {underlying_parents}. Supported types are: {_SUPPORTED_PYTHON_TYPES}"
115+
f"Unsupported type: ({container_types}, {payload_type}) with parents "
116+
f"{underlying_parents}. Supported types are: {_SUPPORTED_PYTHON_TYPES}"
97117
)
98118
_logger.debug(f"Best matching type for '{repr(python_value)}' resolved to {best_matching_type}")
99119
return best_matching_type

src/nipanel/converters/protobuf_types.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@
22

33
from __future__ import annotations
44

5-
import collections.abc
65
import datetime as dt
6+
from collections.abc import Collection, Mapping
77
from typing import Type, Union
88

99
import hightime as ht
1010
import nitypes.bintime as bt
1111
import numpy as np
1212
from ni.protobuf.types import scalar_pb2
13+
from ni_measurement_plugin_sdk_service._internal.stubs.ni.protobuf.types.array_pb2 import (
14+
Double2DArray,
15+
)
1316
from ni_measurement_plugin_sdk_service._internal.stubs.ni.protobuf.types.precision_timestamp_pb2 import (
1417
PrecisionTimestamp,
1518
)
@@ -39,6 +42,51 @@
3942
}
4043

4144

45+
class Double2DArrayConverter(Converter[Collection[Collection[float]], Double2DArray]):
46+
"""A converter between Collection[Collection[float]] and Double2DArray."""
47+
48+
@property
49+
def python_typename(self) -> str:
50+
"""The Python type that this converter handles."""
51+
return f"{Collection.__name__}.{Collection.__name__}.{float.__name__}"
52+
53+
@property
54+
def protobuf_message(self) -> Type[Double2DArray]:
55+
"""The type-specific protobuf message for the Python type."""
56+
return Double2DArray
57+
58+
def to_protobuf_message(self, python_value: Collection[Collection[float]]) -> Double2DArray:
59+
"""Convert the Python Collection[Collection[float]] to a protobuf Double2DArray."""
60+
rows = len(python_value)
61+
if rows:
62+
visitor = iter(python_value)
63+
first_subcollection = next(visitor)
64+
columns = len(first_subcollection)
65+
else:
66+
columns = 0
67+
if not all(len(subcollection) == columns for subcollection in python_value):
68+
raise ValueError("All subcollections must have the same length.")
69+
70+
# Create a flat list in row major order.
71+
flat_list = [item for subcollection in python_value for item in subcollection]
72+
return Double2DArray(rows=rows, columns=columns, data=flat_list)
73+
74+
def to_python_value(self, protobuf_message: Double2DArray) -> Collection[Collection[float]]:
75+
"""Convert the protobuf Double2DArray to a Python Collection[Collection[float]]."""
76+
if not protobuf_message.data:
77+
return []
78+
if len(protobuf_message.data) % protobuf_message.columns != 0:
79+
raise ValueError("The length of the data list must be divisible by num columns.")
80+
81+
# Convert from a flat list in row major order into a list of lists.
82+
list_of_lists = []
83+
for i in range(0, len(protobuf_message.data), protobuf_message.columns):
84+
row = protobuf_message.data[i : i + protobuf_message.columns]
85+
list_of_lists.append(row)
86+
87+
return list_of_lists
88+
89+
4290
class DoubleAnalogWaveformConverter(Converter[AnalogWaveform[np.float64], DoubleAnalogWaveform]):
4391
"""A converter for AnalogWaveform types with scaled data (double)."""
4492

@@ -81,7 +129,7 @@ def to_protobuf_message(self, python_value: AnalogWaveform[np.float64]) -> Doubl
81129
def _extended_properties_to_attributes(
82130
self,
83131
extended_properties: ExtendedPropertyDictionary,
84-
) -> collections.abc.Mapping[str, WaveformAttributeValue]:
132+
) -> Mapping[str, WaveformAttributeValue]:
85133
return {key: self._value_to_attribute(value) for key, value in extended_properties.items()}
86134

87135
def _value_to_attribute(self, value: ExtendedPropertyValue) -> WaveformAttributeValue:

tests/unit/test_convert.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
from typing import Any, Union
1+
from typing import Any, Collection, Union
22

33
import numpy as np
44
import pytest
55
from google.protobuf import any_pb2, wrappers_pb2
66
from google.protobuf.message import Message
77
from ni.protobuf.types.scalar_pb2 import ScalarData
88
from ni.pythonpanel.v1 import python_panel_types_pb2
9+
from ni_measurement_plugin_sdk_service._internal.stubs.ni.protobuf.types.array_pb2 import (
10+
Double2DArray,
11+
)
912
from ni_measurement_plugin_sdk_service._internal.stubs.ni.protobuf.types.waveform_pb2 import (
1013
DoubleAnalogWaveform,
1114
)
@@ -14,6 +17,7 @@
1417
from typing_extensions import TypeAlias
1518

1619
import nipanel._convert
20+
import tests.types
1721

1822

1923
_AnyWrappersPb2: TypeAlias = Union[
@@ -44,6 +48,11 @@
4448
(456.2, "float"),
4549
(123, "int"),
4650
("mystr", "str"),
51+
(tests.types.MyIntFlags.VALUE1, "int"),
52+
(tests.types.MyIntEnum.VALUE10, "int"),
53+
(tests.types.MixinIntEnum.VALUE11, "int"),
54+
(tests.types.MyStrEnum.VALUE1, "str"),
55+
(tests.types.MixinStrEnum.VALUE11, "str"),
4756
([False, False], "Collection.bool"),
4857
([b"mystr", b"mystr"], "Collection.bytes"),
4958
([456.2, 1.0], "Collection.float"),
@@ -69,6 +78,18 @@
6978
(frozenset([456.2, 1.0]), "Collection.float"),
7079
(frozenset([123, 456]), "Collection.int"),
7180
(frozenset(["mystr", "mystr2"]), "Collection.str"),
81+
([[1.0, 2.0], [1.0, 2.0]], "Collection.Collection.float"),
82+
([(1.0, 2.0), (3.0, 4.0)], "Collection.Collection.float"),
83+
([set([1.0, 2.0]), set([3.0, 4.0])], "Collection.Collection.float"),
84+
([frozenset([1.0, 2.0]), frozenset([3.0, 4.0])], "Collection.Collection.float"),
85+
(([1.0, 2.0], [3.0, 4.0]), "Collection.Collection.float"),
86+
(((1.0, 2.0), (3.0, 4.0)), "Collection.Collection.float"),
87+
((set([1.0, 2.0]), set([3.0, 4.0])), "Collection.Collection.float"),
88+
((frozenset([1.0, 2.0]), frozenset([3.0, 4.0])), "Collection.Collection.float"),
89+
(set([(1.0, 2.0), (3.0, 4.0)]), "Collection.Collection.float"),
90+
(set([frozenset([1.0, 2.0]), frozenset([3.0, 4.0])]), "Collection.Collection.float"),
91+
(frozenset([(1.0, 2.0), (3.0, 4.0)]), "Collection.Collection.float"),
92+
(frozenset([frozenset([1.0, 2.0]), frozenset([3.0, 4.0])]), "Collection.Collection.float"),
7293
],
7394
)
7495
def test___various_python_objects___get_best_matching_type___returns_correct_type_string(
@@ -194,6 +215,63 @@ def test___python_analog_waveform___to_any___valid_double_analog_waveform() -> N
194215
assert list(unpack_dest.y_data) == [0.0, 0.0, 0.0]
195216

196217

218+
@pytest.mark.parametrize(
219+
"python_value",
220+
[
221+
# lists of collections
222+
([[1.0, 2.0], [3.0, 4.0]]),
223+
([(1.0, 2.0), (3.0, 4.0)]),
224+
([set([1.0, 2.0]), set([3.0, 4.0])]),
225+
([frozenset([1.0, 2.0]), frozenset([3.0, 4.0])]),
226+
# tuples of collections
227+
(([1.0, 2.0], [3.0, 4.0])),
228+
(((1.0, 2.0), (3.0, 4.0))),
229+
((set([1.0, 2.0]), set([3.0, 4.0]))),
230+
((frozenset([1.0, 2.0]), frozenset([3.0, 4.0]))),
231+
],
232+
)
233+
def test___python_2dcollection_of_float___to_any___valid_double2darray(
234+
python_value: Collection[Collection[float]],
235+
) -> None:
236+
expected_data = [1.0, 2.0, 3.0, 4.0]
237+
expected_rows = 2
238+
expected_columns = 2
239+
result = nipanel._convert.to_any(python_value)
240+
unpack_dest = Double2DArray()
241+
_assert_any_and_unpack(result, unpack_dest)
242+
243+
assert isinstance(unpack_dest, Double2DArray)
244+
assert unpack_dest.rows == expected_rows
245+
assert unpack_dest.columns == expected_columns
246+
assert unpack_dest.data == expected_data
247+
248+
249+
@pytest.mark.parametrize(
250+
"python_value",
251+
[
252+
(set([(1.0, 2.0), (3.0, 4.0)])),
253+
(set([frozenset([1.0, 2.0]), frozenset([3.0, 4.0])])),
254+
(frozenset([(1.0, 2.0), (3.0, 4.0)])),
255+
(frozenset([frozenset([1.0, 2.0]), frozenset([3.0, 4.0])])),
256+
],
257+
)
258+
def test___python_set_of_collection_of_float___to_any___valid_double2darray(
259+
python_value: Collection[Collection[float]],
260+
) -> None:
261+
expected_data = [1.0, 2.0, 3.0, 4.0]
262+
expected_rows = 2
263+
expected_columns = 2
264+
result = nipanel._convert.to_any(python_value)
265+
unpack_dest = Double2DArray()
266+
_assert_any_and_unpack(result, unpack_dest)
267+
268+
assert isinstance(unpack_dest, Double2DArray)
269+
assert unpack_dest.rows == expected_rows
270+
assert unpack_dest.columns == expected_columns
271+
# Sets and frozensets don't maintain order, so sort before comparing.
272+
assert sorted(unpack_dest.data) == sorted(expected_data)
273+
274+
197275
# ========================================================
198276
# Protobuf Types: Protobuf to Python
199277
# ========================================================
@@ -219,6 +297,17 @@ def test___double_analog_waveform___from_any___valid_python_analog_waveform() ->
219297
assert result.dtype == np.float64
220298

221299

300+
def test___double2darray___from_any___valid_python_2dcollection() -> None:
301+
pb_value = Double2DArray(data=[1.0, 2.0, 3.0, 4.0], rows=2, columns=2)
302+
packed_any = _pack_into_any(pb_value)
303+
304+
result = nipanel._convert.from_any(packed_any)
305+
306+
expected_value = [[1.0, 2.0], [3.0, 4.0]]
307+
assert isinstance(result, type(expected_value))
308+
assert result == expected_value
309+
310+
222311
# ========================================================
223312
# Pack/Unpack Helpers
224313
# ========================================================

0 commit comments

Comments
 (0)