Skip to content

Commit 81da80e

Browse files
committed
Implement listener cancellation
1 parent e7170c0 commit 81da80e

File tree

2 files changed

+103
-2
lines changed

2 files changed

+103
-2
lines changed

tests/test_api.py

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import functools
44
import async_timeout
55

6-
from unittest.mock import Mock
6+
from unittest.mock import Mock, call
77

88
try:
99
from unittest.mock import AsyncMock # noqa: F401
@@ -15,7 +15,12 @@
1515

1616
from zigpy_znp.types import nvids
1717

18-
from zigpy_znp.api import ZNP, _deduplicate_commands
18+
from zigpy_znp.api import (
19+
ZNP,
20+
_deduplicate_commands,
21+
OneShotResponseListener,
22+
CallbackResponseListener,
23+
)
1924
from zigpy_znp.frames import TransportFrame
2025

2126

@@ -510,3 +515,81 @@ async def test_znp_nvram_writes(znp, event_loop):
510515
# Writing too big of a value should fail
511516
with pytest.raises(ValueError):
512517
await znp.nvram_write(nvids.NwkNvIds.STARTUP_OPTION, t.uint16_t(0xAABB))
518+
519+
520+
@pytest_mark_asyncio_timeout()
521+
async def test_listeners_resolve(event_loop):
522+
callback = Mock()
523+
callback_listener = CallbackResponseListener(
524+
[c.SysCommands.Ping.Rsp(partial=True)], callback
525+
)
526+
527+
future = event_loop.create_future()
528+
one_shot_listener = OneShotResponseListener(
529+
[c.SysCommands.Ping.Rsp(partial=True)], future
530+
)
531+
532+
match = c.SysCommands.Ping.Rsp(Capabilities=c.types.MTCapabilities.CAP_SYS)
533+
no_match = c.SysCommands.OSALNVWrite.Rsp(Status=t.Status.Success)
534+
535+
assert callback_listener.resolve(match)
536+
assert not callback_listener.resolve(no_match)
537+
assert callback_listener.resolve(match)
538+
assert not callback_listener.resolve(no_match)
539+
540+
assert one_shot_listener.resolve(match)
541+
assert not one_shot_listener.resolve(no_match)
542+
543+
callback.assert_has_calls([call(match), call(match)])
544+
assert callback.call_count == 2
545+
546+
assert (await future) == match
547+
548+
# Cancelling a callback will have no effect
549+
assert not callback_listener.cancel()
550+
551+
# Cancelling a one-shot listener does not throw any errors
552+
assert one_shot_listener.cancel()
553+
assert one_shot_listener.cancel()
554+
assert one_shot_listener.cancel()
555+
556+
557+
@pytest_mark_asyncio_timeout()
558+
async def test_listener_cancel(event_loop):
559+
# Cancelling a one-shot listener prevents it from being fired
560+
future = event_loop.create_future()
561+
one_shot_listener = OneShotResponseListener(
562+
[c.SysCommands.Ping.Rsp(partial=True)], future
563+
)
564+
one_shot_listener.cancel()
565+
566+
match = c.SysCommands.Ping.Rsp(Capabilities=c.types.MTCapabilities.CAP_SYS)
567+
assert not one_shot_listener.resolve(match)
568+
569+
with pytest.raises(asyncio.CancelledError):
570+
await future
571+
572+
573+
@pytest_mark_asyncio_timeout()
574+
async def test_listeners_cancel(event_loop):
575+
callback = Mock()
576+
callback_listener = CallbackResponseListener(
577+
[c.SysCommands.Ping.Rsp(partial=True)], callback
578+
)
579+
580+
future = event_loop.create_future()
581+
one_shot_listener = OneShotResponseListener(
582+
[c.SysCommands.Ping.Rsp(partial=True)], future
583+
)
584+
585+
match = c.SysCommands.Ping.Rsp(Capabilities=c.types.MTCapabilities.CAP_SYS)
586+
no_match = c.SysCommands.OSALNVWrite.Rsp(Status=t.Status.Success)
587+
588+
assert callback_listener.resolve(match)
589+
assert not callback_listener.resolve(no_match)
590+
591+
assert one_shot_listener.resolve(match)
592+
assert not one_shot_listener.resolve(no_match)
593+
594+
callback.assert_called_once_with(match)
595+
assert (await future) == match

zigpy_znp/api.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ def _resolve(self, command: CommandBase) -> bool:
8686
"""
8787
raise NotImplementedError() # pragma: no cover
8888

89+
def cancel(self):
90+
"""
91+
Implement by subclasses to cancel the listener.
92+
93+
Return value indicates whether or not the listener is cancelable.
94+
"""
95+
raise NotImplementedError() # pragma: no cover
96+
8997

9098
@attr.s(frozen=True)
9199
class OneShotResponseListener(BaseResponseListener):
@@ -105,6 +113,12 @@ def _resolve(self, command: CommandBase) -> bool:
105113
self.future.set_result(command)
106114
return True
107115

116+
def cancel(self):
117+
if not self.future.done():
118+
self.future.cancel()
119+
120+
return True
121+
108122

109123
@attr.s(frozen=True)
110124
class CallbackResponseListener(BaseResponseListener):
@@ -125,6 +139,10 @@ def _resolve(self, command: CommandBase) -> bool:
125139
# Returning False could cause our callback to be called multiple times in a row
126140
return True
127141

142+
def cancel(self):
143+
# You can't cancel a callback
144+
return False
145+
128146

129147
class ZNP:
130148
def __init__(self):

0 commit comments

Comments
 (0)