Skip to content

Commit 307d785

Browse files
authored
Merge pull request #8 from nocarryr/codspeed
Codspeed
2 parents 3e4fe06 + 0b89e03 commit 307d785

File tree

6 files changed

+321
-16
lines changed

6 files changed

+321
-16
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,4 @@ ENV/
9191
venv*
9292
.venv*
9393
.pytest_cache/
94+
.codspeed/

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ test = [
5959
"faker>=35.2.2",
6060
"pytest>=8.3.5",
6161
"pytest-asyncio>=0.24.0",
62+
"pytest-codspeed>=4.1.1",
6263
"pytest-cov>=5.0.0",
6364
"pytest-doctestplus>=1.3.0",
6465
"pytest-timeout>=2.4.0",

src/tslumd/messages.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,20 @@ class Display:
103103
104104
.. versionadded:: 0.0.2
105105
"""
106+
text_length: int|None = field(default=None, compare=False)
107+
"""Length of the :attr:`text` field
108+
109+
If provided, the text output will be zero-padded or truncated to this length.
110+
If ``None`` (the default), the text length is variable.
111+
112+
113+
.. note::
114+
115+
This is mainly for internal use during test runs to match the fixed-length
116+
text field in actual message bytes.
117+
118+
.. versionadded:: 0.0.8
119+
"""
106120

107121
def __post_init__(self):
108122
self.is_broadcast = self.index == 0xffff
@@ -124,11 +138,23 @@ def broadcast(cls, **kwargs) -> Display:
124138
return cls(**kwargs)
125139

126140
@classmethod
127-
def from_dmsg(cls, flags: Flags, dmsg: bytes) -> Tuple[Display, bytes]:
141+
def from_dmsg(cls, flags: Flags, dmsg: bytes, retain_text_length: bool = False) -> Tuple[Display, bytes]:
128142
"""Construct an instance from a ``DMSG`` portion of received message.
129143
130144
Any remaining message data after the relevant ``DMSG`` is returned along
131145
with the instance.
146+
147+
Arguments:
148+
flags: The message :class:`Flags` field
149+
dmsg: The portion of the message containing the ``DMSG`` data
150+
retain_text_length: If ``True``, the :attr:`text_length` attribute
151+
will be set to the length of the text field as found in the
152+
message bytes. Otherwise (the default), it will be set to ``None``
153+
and the text length will be variable.
154+
155+
.. versionchanged:: 0.0.8
156+
157+
The `retain_text_length` argument was added.
132158
"""
133159
if len(dmsg) < 4:
134160
raise DmsgParseError('Invalid dmsg length', dmsg)
@@ -163,9 +189,14 @@ def from_dmsg(cls, flags: Flags, dmsg: bytes) -> Tuple[Display, bytes]:
163189
if Flags.UTF16 in flags:
164190
txt = txt_bytes.decode('UTF-16le')
165191
else:
192+
txt_length = len(txt_bytes)
166193
if b'\0' in txt_bytes:
167194
txt_bytes = txt_bytes.split(b'\0')[0]
168195
txt = txt_bytes.decode('UTF-8')
196+
if retain_text_length:
197+
kw['text_length'] = txt_length
198+
else:
199+
kw['text_length'] = None
169200
kw['text'] = txt
170201
return cls(**kw), dmsg
171202

@@ -236,6 +267,8 @@ def to_dmsg(self, flags: Flags) -> bytes:
236267
txt_bytes = bytes(self.text, 'UTF-16le')
237268
else:
238269
txt_bytes = bytes(self.text, 'UTF-8')
270+
if self.text_length is not None:
271+
txt_bytes = txt_bytes.ljust(self.text_length, b'\0')[:self.text_length]
239272
txt_byte_len = len(txt_bytes)
240273
data = bytearray(struct.pack('<3H', self.index, ctrl, txt_byte_len))
241274
data.extend(txt_bytes)
@@ -244,6 +277,7 @@ def to_dmsg(self, flags: Flags) -> bytes:
244277
def to_dict(self) -> dict:
245278
d = dataclasses.asdict(self)
246279
del d['is_broadcast']
280+
del d['text_length']
247281
return d
248282

249283
@classmethod
@@ -352,10 +386,17 @@ def broadcast(cls, **kwargs) -> Message:
352386
return cls(**kwargs)
353387

354388
@classmethod
355-
def parse(cls, msg: bytes) -> Tuple[Message, bytes]:
389+
def parse(cls, msg: bytes, retain_text_length: bool = False) -> Tuple[Message, bytes]:
356390
"""Parse incoming message data to create a :class:`Message` instance.
357391
358392
Any remaining message data after parsing is returned along with the instance.
393+
394+
Arguments:
395+
msg: The incoming message bytes to parse
396+
retain_text_length: Value to pass to :meth:`Display.from_dmsg`
397+
398+
.. versionchanged:: 0.0.8
399+
The `retain_text_length` argument was added.
359400
"""
360401
if len(msg) < 6:
361402
raise MessageParseError('Invalid header length', msg)
@@ -380,7 +421,7 @@ def parse(cls, msg: bytes) -> Tuple[Message, bytes]:
380421
obj.scontrol = msg
381422
return obj, remaining
382423
while len(msg):
383-
disp, msg = Display.from_dmsg(obj.flags, msg)
424+
disp, msg = Display.from_dmsg(obj.flags, msg, retain_text_length)
384425
obj.displays.append(disp)
385426
return obj, remaining
386427

tests/conftest.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,30 @@ def uhs500_msg_parsed() -> Message:
3838
data['displays'] = displays
3939
return Message(**data)
4040

41+
42+
@pytest.fixture
43+
def uhs500_msg_parsed_fixed_text_length() -> Message:
44+
"""Expected :class:`~tslumd.messages.Message` object
45+
matching data from :func:`uhs500_msg_bytes`
46+
47+
This is the same as :func:`uhs500_msg_parsed` except that
48+
the `text_length` attribute of each display is set to 12,
49+
which is the fixed length used in the actual message bytes (zero-padded).
50+
"""
51+
52+
txt_length = 12
53+
data = json.loads(MESSAGE_JSON.read_text())
54+
data['scontrol'] = b''
55+
displays = []
56+
for disp in data['displays']:
57+
for key in ['rh_tally', 'txt_tally', 'lh_tally']:
58+
disp[key] = getattr(TallyColor, disp[key])
59+
disp['text_length'] = txt_length
60+
displays.append(Display(**disp))
61+
data['displays'] = displays
62+
return Message(**data)
63+
64+
4165
@pytest.fixture
4266
def udp_port0(unused_udp_port_factory):
4367
return unused_udp_port_factory()

tests/test_messages.py

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,46 @@
77
DmsgControlParseError, MessageParseError, MessageLengthError,
88
)
99

10-
@pytest.fixture
11-
def message_with_lots_of_displays():
10+
11+
def build_multi_display_message(num_displays: int) -> tuple[Message, list[int]]:
12+
"""Build a message with enough displays to require multiple packets
13+
when built, returning the message object and a list of the lengths
14+
of each packet.
15+
"""
1216
msgobj = Message()
13-
# Message header byte length: 6
14-
# Dmsg header byte length: 4
15-
# Text length: 2(length bytes) + 9(chars) = 11
16-
# Each Dmsg total: 4 + 11 = 15
17-
# Dmsg's: 4096 * 15 = 61440
18-
# 4096 Dmsg's with Message header: 4096 * 15 + 6 = 61446 bytes
19-
for i in range(4096):
20-
msgobj.displays.append(Display(index=i, text=f'Foo {i:05d}'))
21-
return msgobj
17+
msg_lengths = []
18+
text_format = 'Foo {:05d}'
19+
text_length = len(text_format.format(0))
20+
21+
# Initial message header length
22+
cur_msg_length = 6
23+
24+
# Dmsg header + text length bytes + text bytes
25+
dmsg_length = 4 + 2 + text_length
26+
27+
for i in range(num_displays):
28+
msgobj.displays.append(Display(index=i, text=text_format.format(i)))
29+
if cur_msg_length + dmsg_length > 2048:
30+
msg_lengths.append(cur_msg_length)
31+
cur_msg_length = 6 + dmsg_length
32+
else:
33+
cur_msg_length += dmsg_length
34+
msg_lengths.append(cur_msg_length)
35+
assert not any(l > 2048 for l in msg_lengths)
36+
return msgobj, msg_lengths
37+
38+
39+
@pytest.fixture
40+
def message_with_multi_packet_displays() -> tuple[Message, list[int]]:
41+
"""Message with enough displays to require at least 6 packets
42+
but not so many as to make the test take too long.
43+
"""
44+
return build_multi_display_message(6 * 136 + 1) # 6 full packets + 1 display
45+
46+
47+
@pytest.fixture
48+
def message_with_lots_of_displays() -> tuple[Message, list[int]]:
49+
return build_multi_display_message(4096)
2250

2351

2452
def test_uhs_message(uhs500_msg_bytes, uhs500_msg_parsed):
@@ -60,7 +88,7 @@ def test_messages():
6088

6189

6290
def test_packet_length(faker, message_with_lots_of_displays):
63-
msgobj = message_with_lots_of_displays
91+
msgobj, msg_lengths = message_with_lots_of_displays
6492

6593
# Make sure the 2048 byte limit is respected
6694
with pytest.raises(MessageLengthError):
@@ -73,8 +101,9 @@ def test_packet_length(faker, message_with_lots_of_displays):
73101

74102
# Iterate over individual message packets and make sure we get all displays
75103
all_parsed_displays = []
76-
for msg_bytes in msgobj.build_messages():
104+
for i, msg_bytes in enumerate(msgobj.build_messages()):
77105
assert len(msg_bytes) <= 2048
106+
assert len(msg_bytes) == msg_lengths[i]
78107
parsed, remaining = Message.parse(msg_bytes)
79108
assert not len(remaining)
80109
all_parsed_displays.extend(parsed.displays)
@@ -321,3 +350,26 @@ def test_invalid_dmsg_control(uhs500_msg_bytes, faker):
321350
bad_bytes = bytes(bad_bytes)
322351
with pytest.raises(DmsgControlParseError):
323352
r = Message.parse(bad_bytes)
353+
354+
355+
@pytest.mark.benchmark(group='message-parse')
356+
def test_bench_message_parse(uhs500_msg_bytes, uhs500_msg_parsed_fixed_text_length):
357+
parsed, remaining = Message.parse(uhs500_msg_bytes, retain_text_length=True)
358+
assert not len(remaining)
359+
assert parsed == uhs500_msg_parsed_fixed_text_length
360+
361+
362+
@pytest.mark.benchmark(group='message-build')
363+
def test_bench_message_build(uhs500_msg_bytes, uhs500_msg_parsed_fixed_text_length):
364+
msg_bytes = uhs500_msg_parsed_fixed_text_length.build_message()
365+
assert len(msg_bytes) == len(uhs500_msg_bytes)
366+
assert msg_bytes == uhs500_msg_bytes
367+
368+
369+
@pytest.mark.benchmark(group='message-build-multi')
370+
def test_bench_message_build_multi(message_with_multi_packet_displays):
371+
msgobj, msg_lengths = message_with_multi_packet_displays
372+
assert len(msg_lengths) > 1
373+
for i, msg_bytes in enumerate(msgobj.build_messages()):
374+
msg_len = len(msg_bytes)
375+
assert msg_len == msg_lengths[i]

0 commit comments

Comments
 (0)