Skip to content

Commit 9aab2d4

Browse files
authored
Correct type handling for bitwise variable access (#663)
* Correct type handling in decode_bits() and encode_bits(). Contrary to the existing type hints, the methods can handle strings for lookup as well as an arbitrary collection of integer bit offsets. Correct the hints to reflect that and check at runtime to only lookup from strings. Correct return type for encode_bits(), as it works only for integers. Document both functions to explain the intention and caveats to consider, especially the lookup KeyError exception. Extend test to cover an iterable and a failed lookup. * Add unit tests involving sparse bit offset enumerations. Test encoding and decoding with non-contiguous bit offsets. * Fix unintended bit overwriting with sparse bit offsets. Any bit for which the replacement pattern contained a 1 bit in one of the "holes", would be set despite not being part of the desired mask. * Correct type annotations for class Bits. This mechanism works only for integers, so add appropriate assertions. Document all expected types for indexing the Bits. * Test non-string keys for bit access. * Fix slice-based access without step, useful exception without stop. * Fix slice-based access without start, cover in test.
1 parent 6688d63 commit 9aab2d4

4 files changed

Lines changed: 76 additions & 23 deletions

File tree

canopen/objectdictionary/__init__.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import logging
88
import struct
9-
from collections.abc import Iterator, Mapping, MutableMapping
9+
from collections.abc import Collection, Iterator, Mapping, MutableMapping
1010
from typing import Optional, TextIO, Union
1111

1212
from canopen.objectdictionary.datatypes import *
@@ -516,27 +516,44 @@ def encode_desc(self, desc: str) -> int:
516516
raise ValueError(
517517
f"No value corresponds to '{desc}'. Valid values are: {valid_values}")
518518

519-
def decode_bits(self, value: int, bits: list[int]) -> int:
520-
try:
519+
def decode_bits(self, value: int, bits: Union[str, Collection[int]]) -> int:
520+
"""Isolate and right-shift the specified bits from a given integer.
521+
522+
:param value: Variable value holding the bits
523+
:param bits: Registered lookup name or concrete list of bit offsets
524+
:return: Extracted bits, right-shifted to cut off to lowest specified offset
525+
:raises KeyError: For unknown lookup names
526+
"""
527+
if isinstance(bits, str):
521528
bits = self.bit_definitions[bits]
522-
except (TypeError, KeyError):
523-
pass
524529
mask = 0
525530
for bit in bits:
526531
mask |= 1 << bit
527532
return (value & mask) >> min(bits)
528533

529-
def encode_bits(self, original_value: int, bits: list[int], bit_value: int):
530-
try:
534+
def encode_bits(
535+
self, original_value: int, bits: Union[str, Collection[int]], bit_value: int
536+
) -> int:
537+
"""Replace the specified bits with the given (unshifted) pattern.
538+
539+
The bit offsets sequence may be non-contiguous, but the replacement pattern
540+
must specify all bits including the "holes". It is only shifted once, so the
541+
LSB lands at the lowest specified bit offset.
542+
543+
:param original_value: Variable value holding the bits
544+
:param bits: Registered lookup name or concrete list of bit offsets
545+
:param bit_value: Source pattern to overwrite with
546+
:return: Merged value with the bits replaced
547+
:raises KeyError: For unknown lookup names
548+
"""
549+
if isinstance(bits, str):
531550
bits = self.bit_definitions[bits]
532-
except (TypeError, KeyError):
533-
pass
534551
temp = original_value
535552
mask = 0
536553
for bit in bits:
537554
mask |= 1 << bit
538555
temp &= ~mask
539-
temp |= bit_value << min(bits)
556+
temp |= (bit_value << min(bits)) & mask
540557
return temp
541558

542559

canopen/variable.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
from __future__ import annotations
2+
13
import logging
2-
from collections.abc import Mapping
4+
from collections.abc import Collection, Mapping
35
from typing import Union
46

57
from canopen import objectdictionary
@@ -127,7 +129,7 @@ def desc(self, desc: str):
127129
self.raw = self.od.encode_desc(desc)
128130

129131
@property
130-
def bits(self) -> "Bits":
132+
def bits(self) -> Bits:
131133
"""Access bits using integers, slices, or bit descriptions."""
132134
return Bits(self)
133135

@@ -183,23 +185,26 @@ def write(
183185
class Bits(Mapping):
184186

185187
def __init__(self, variable: Variable):
188+
assert variable.od.data_type in objectdictionary.datatypes.INTEGER_TYPES
186189
self.variable = variable
187190
self.read()
191+
self.raw: int
188192

189193
@staticmethod
190-
def _get_bits(key):
194+
def _get_bits(key: Union[slice, int, str, Collection[int]]) -> Union[str, Collection[int]]:
191195
if isinstance(key, slice):
192-
bits = range(key.start, key.stop, key.step)
193-
elif isinstance(key, int):
194-
bits = [key]
195-
else:
196-
bits = key
197-
return bits
198-
199-
def __getitem__(self, key) -> int:
196+
if key.stop is None:
197+
raise IndexError("Bits cannot be enumerated from open-ended slice")
198+
else:
199+
return range(key.start or 0, key.stop, key.step or 1)
200+
if isinstance(key, int):
201+
return [key]
202+
return key
203+
204+
def __getitem__(self, key: Union[slice, int, str, Collection[int]]) -> int:
200205
return self.variable.od.decode_bits(self.raw, self._get_bits(key))
201206

202-
def __setitem__(self, key, value: int):
207+
def __setitem__(self, key: Union[slice, int, str, Collection[int]], value: int):
203208
self.raw = self.variable.od.encode_bits(
204209
self.raw, self._get_bits(key), value)
205210
self.write()
@@ -211,7 +216,8 @@ def __len__(self):
211216
return len(self.variable.od.bit_definitions)
212217

213218
def read(self):
214-
self.raw = self.variable.raw
219+
assert isinstance(raw_int := self.variable.raw, int)
220+
self.raw = raw_int
215221

216222
def write(self):
217223
self.variable.raw = self.raw

test/test_od.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,10 +257,26 @@ def test_bits(self):
257257
self.assertEqual(var.decode_bits(1, "BIT 0"), 1)
258258
self.assertEqual(var.decode_bits(1, [1]), 0)
259259
self.assertEqual(var.decode_bits(0xf, [0, 1, 2, 3]), 15)
260+
self.assertEqual(var.decode_bits(0xf, range(4)), 15)
260261
self.assertEqual(var.decode_bits(8, "BIT 2 and 3"), 2)
261262
self.assertEqual(var.encode_bits(0xf, [1], 0), 0xd)
262263
self.assertEqual(var.encode_bits(0, "BIT 0", 1), 1)
263264

265+
with self.assertRaises(KeyError):
266+
var.decode_bits(0, "DOES NOT EXIST")
267+
with self.assertRaises(KeyError):
268+
var.encode_bits(0, "DOES NOT EXIST", 0)
269+
270+
def test_bits_sparse(self):
271+
var = od.ODVariable("Test UNSIGNED8", 0x1000)
272+
var.data_type = od.UNSIGNED8
273+
274+
self.assertEqual(var.decode_bits(0b11111111, [2, 5]), 0b1001)
275+
self.assertEqual(var.decode_bits(0b11011011, [2, 5]), 0)
276+
self.assertEqual(var.encode_bits(0b11111111, [2, 5], 0), 0b11011011)
277+
self.assertEqual(var.encode_bits(0b00000000, [2, 5], 0b1001), 0b00100100)
278+
self.assertEqual(var.encode_bits(0b00000000, [2, 5], 0b1111), 0b00100100)
279+
264280

265281
class TestObjectDictionary(unittest.TestCase):
266282

test/test_variable.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,20 @@ def test_bits(self):
6565
bits[0] = 0
6666
self.assertEqual(v.raw, 4)
6767

68+
def test_bits_non_string(self):
69+
var = od.ODVariable("Test UNSIGNED8", 0x1000)
70+
var.data_type = od.UNSIGNED8
71+
var.default = 0
72+
v = _StubVariable(var)
73+
v.raw = 5
74+
bits = v.bits
75+
self.assertEqual(bits[range(1, 3)], 2)
76+
self.assertEqual(bits[1:3], 2)
77+
self.assertEqual(bits[0:3:2], 5)
78+
self.assertEqual(bits[:3], 5)
79+
with self.assertRaises(IndexError):
80+
bits[1:]
81+
6882

6983
if __name__ == "__main__":
7084
unittest.main()

0 commit comments

Comments
 (0)