From: yc999.jang Date: Tue, 20 Oct 2020 07:36:02 +0000 (+0900) Subject: Add sdlpy wrapping functions X-Git-Tag: 1.6.0~1 X-Git-Url: https://gerrit.o-ran-sc.org/r/gitweb?a=commitdiff_plain;h=da91199662c2d6d871f21d6d597df39545d26be1;p=ric-plt%2Fxapp-frame-py.git Add sdlpy wrapping functions Update ricsdl version requirement for notification support Issue-ID: RIC-662 Signed-off-by: Youngcheol Jang Change-Id: Ice8c68b25a5f4f7e5a9e5426ebc16e5fa8cc7b82 --- diff --git a/docs/release-notes.rst b/docs/release-notes.rst index 1b82c2d..b2f0dcc 100644 --- a/docs/release-notes.rst +++ b/docs/release-notes.rst @@ -11,6 +11,11 @@ The format is based on `Keep a Changelog `__ and this project adheres to `Semantic Versioning `__. +[1.6.0] - 2020-10-23 +-------------------- +* Add SDL wrapping API (`RIC-659 `_) + + [1.5.0] - 2020-07-10 -------------------- * Add Metrics API (`RIC-381 `_) diff --git a/ricxappframe/xapp_frame.py b/ricxappframe/xapp_frame.py index 11ea210..8abc443 100644 --- a/ricxappframe/xapp_frame.py +++ b/ricxappframe/xapp_frame.py @@ -83,7 +83,7 @@ class _BaseXapp: self._mrc = self._rmr_loop.mrc # for convenience # SDL - self._sdl = SDLWrapper(use_fake_sdl) + self.sdl = SDLWrapper(use_fake_sdl) # Config # The environment variable specifies the path to the Xapp config file @@ -193,6 +193,9 @@ class _BaseXapp: def sdl_set(self, namespace, key, value, usemsgpack=True): """ + ** Deprecate Warning ** + ** Will be removed in a future function ** + Stores a key-value pair to SDL, optionally serializing the value to bytes using msgpack. @@ -212,10 +215,13 @@ class _BaseXapp: that is serializable by msgpack. If usemsgpack is False, the value must be bytes. """ - self._sdl.set(namespace, key, value, usemsgpack) + self.sdl.set(namespace, key, value, usemsgpack) def sdl_get(self, namespace, key, usemsgpack=True): """ + ** Deprecate Warning ** + ** Will be removed in a future function ** + Gets the value for the specified namespace and key from SDL, optionally deserializing stored bytes using msgpack. @@ -237,10 +243,13 @@ class _BaseXapp: See the usemsgpack parameter for an explanation of the returned value type. Answers None if the key is not found. """ - return self._sdl.get(namespace, key, usemsgpack) + return self.sdl.get(namespace, key, usemsgpack) def sdl_find_and_get(self, namespace, prefix, usemsgpack=True): """ + ** Deprecate Warning ** + ** Will be removed in a future function ** + Gets all key-value pairs in the specified namespace with keys that start with the specified prefix, optionally deserializing stored bytes using msgpack. @@ -265,10 +274,13 @@ class _BaseXapp: but is either a Python object or raw bytes as discussed above. Answers an empty dictionary if no keys matched the prefix. """ - return self._sdl.find_and_get(namespace, prefix, usemsgpack) + return self.sdl.find_and_get(namespace, prefix, usemsgpack) def sdl_delete(self, namespace, key): """ + ** Deprecate Warning ** + ** Will be removed in a future function ** + Deletes the key-value pair with the specified key in the specified namespace. Parameters @@ -278,7 +290,7 @@ class _BaseXapp: key: string SDL key """ - self._sdl.delete(namespace, key) + self.sdl.delete(namespace, key) # Health @@ -286,7 +298,7 @@ class _BaseXapp: """ this needs to be understood how this is supposed to work """ - return self._rmr_loop.healthcheck() and self._sdl.healthcheck() + return self._rmr_loop.healthcheck() and self.sdl.healthcheck() # Convenience function for discovering config change events diff --git a/ricxappframe/xapp_sdl.py b/ricxappframe/xapp_sdl.py index 17e48b9..a1bf722 100644 --- a/ricxappframe/xapp_sdl.py +++ b/ricxappframe/xapp_sdl.py @@ -83,6 +83,70 @@ class SDLWrapper: value = msgpack.packb(value, use_bin_type=True) self._sdl.set(ns, {key: value}) + def set_if(self, ns, key, old_value, new_value, usemsgpack=True): + """ + Conditionally modify the value of a key if the current value in data storage matches the + user's last known value. + + Parameters + ---------- + ns: string + SDL namespace + key: string + SDL key + old_value: + Lask known object or byte array. See the `usemsgpack` parameter. + new_value: + Object or byte array to be written. See the `usemsgpack` parameter. + usemsgpack: boolean (optional, default is True) + Determines whether the value is serialized using msgpack before storing. + If usemsgpack is True, the msgpack function `packb` is invoked + on the value to yield a byte array that is then sent to SDL. + Stated differently, if usemsgpack is True, the value can be anything + that is serializable by msgpack. + If usemsgpack is False, the value must be bytes. + + Returns + ------- + bool + True for successful modification, false if the user's last known data did not + match the current value in data storage. + """ + if usemsgpack: + old_value = msgpack.packb(old_value, use_bin_type=True) + new_value = msgpack.packb(new_value, use_bin_type=True) + return self._sdl.set_if(ns, key, old_value, new_value) + + def set_if_not_exists(self, ns, key, value, usemsgpack=True): + """ + Write data to SDL storage if key does not exist. + + Parameters + ---------- + ns: string + SDL namespace + key: string + SDL key + value: + Object or byte array to store. See the `usemsgpack` parameter. + usemsgpack: boolean (optional, default is True) + Determines whether the value is serialized using msgpack before storing. + If usemsgpack is True, the msgpack function `packb` is invoked + on the value to yield a byte array that is then sent to SDL. + Stated differently, if usemsgpack is True, the value can be anything + that is serializable by msgpack. + If usemsgpack is False, the value must be bytes. + + Returns + ------- + bool + True for successful modification, false if the user's last known data did not + match the current value in data storage. + """ + if usemsgpack: + value = msgpack.packb(value, use_bin_type=True) + return self._sdl.set_if_not_exists(ns, key, value) + def get(self, ns, key, usemsgpack=True): """ Gets the value for the specified namespace and key, @@ -114,6 +178,24 @@ class SDLWrapper: result = msgpack.unpackb(result, raw=False) return result + def find_keys(self, ns, prefix): + """ + Find all keys matching search pattern under the namespace. + + Parameters + ---------- + ns: string + SDL namespace + prefix: string + Key search pattern + + Returns + ------- + keys: list + A list of found keys. + """ + return self._sdl.find_keys(ns, f"{prefix}*") + def find_and_get(self, ns, prefix, usemsgpack=True): """ Gets all key-value pairs in the specified namespace @@ -141,7 +223,7 @@ class SDLWrapper: """ # note: SDL "*" usage is inconsistent with real python regex, where it would be ".*" - ret_dict = self._sdl.find_and_get(ns, "{0}*".format(prefix)) + ret_dict = self._sdl.find_and_get(ns, f"{prefix}*") if usemsgpack: ret_dict = {k: msgpack.unpackb(v, raw=False) for k, v in ret_dict.items()} return ret_dict @@ -159,6 +241,409 @@ class SDLWrapper: """ self._sdl.remove(ns, {key}) + def delete_if(self, ns, key, value, usemsgpack=True): + """ + Conditionally remove data from SDL storage if the current data value matches the user's + last known value. + + Parameters + ---------- + ns: string + SDL namespace + key: string + SDL key + value: + Object or byte array to store. See the `usemsgpack` parameter. + usemsgpack: boolean (optional, default is True) + Determines whether the value is serialized using msgpack before storing. + If usemsgpack is True, the msgpack function `packb` is invoked + on the value to yield a byte array that is then sent to SDL. + Stated differently, if usemsgpack is True, the value can be anything + that is serializable by msgpack. + If usemsgpack is False, the value must be bytes. + + Returns + ------- + bool + True if successful removal, false if the user's last known data did not match the + current value in data storage. + """ + if usemsgpack: + value = msgpack.packb(value, use_bin_type=True) + return self._sdl.remove_if(ns, key, value) + + def add_member(self, ns, group, member, usemsgpack=True): + """ + Add new members to a SDL group under the namespace. + + Parameters + ---------- + ns: string + SDL namespace + group: string + group name + member: + member to be added + usemsgpack: boolean (optional, default is True) + Determines whether the member is serialized using msgpack before storing. + If usemsgpack is True, the msgpack function `packb` is invoked + on the member to yield a byte array that is then sent to SDL. + Stated differently, if usemsgpack is True, the member can be anything + that is serializable by msgpack. + If usemsgpack is False, the member must be bytes. + """ + if usemsgpack: + member = msgpack.packb(member, use_bin_type=True) + self._sdl.add_member(ns, group, {member}) + + def remove_member(self, ns, group, member, usemsgpack=True): + """ + Remove members from a SDL group. + + Parameters + ---------- + ns: string + SDL namespace + group: string + group name + member: + member to be removed + usemsgpack: boolean (optional, default is True) + Determines whether the member is serialized using msgpack before storing. + If usemsgpack is True, the msgpack function `packb` is invoked + on the member to yield a byte array that is then sent to SDL. + Stated differently, if usemsgpack is True, the member can be anything + that is serializable by msgpack. + If usemsgpack is False, the member must be bytes. + """ + if usemsgpack: + member = msgpack.packb(member, use_bin_type=True) + self._sdl.remove_member(ns, group, {member}) + + def remove_group(self, ns, group): + """ + Remove a SDL group along with its members. + + Parameters + ---------- + ns: string + SDL namespace + group: string + group name to remove + usemsgpack: boolean (optional, default is True) + Determines whether the member is serialized using msgpack before storing. + If usemsgpack is True, the msgpack function `packb` is invoked + on the member to yield a byte array that is then sent to SDL. + Stated differently, if usemsgpack is True, the member can be anything + that is serializable by msgpack. + If usemsgpack is False, the member must be bytes. + """ + self._sdl.remove_group(ns, group) + + def get_members(self, ns, group, usemsgpack=True): + """ + Get all the members of a SDL group. + + Parameters + ---------- + ns: string + SDL namespace + group: string + group name to retrive + usemsgpack: boolean (optional, default is True) + Determines whether the member is serialized using msgpack before storing. + If usemsgpack is True, the msgpack function `packb` is invoked + on the member to yield a byte array that is then sent to SDL. + Stated differently, if usemsgpack is True, the member can be anything + that is serializable by msgpack. + If usemsgpack is False, the member must be bytes. + + Returns + ------- + Set[str] or Set[bytes] + A set of the members of the group. + None + """ + ret_set = self._sdl.get_members(ns, group) + if usemsgpack: + ret_set = {msgpack.unpackb(m, raw=False) for m in ret_set} + return ret_set + + def is_member(self, ns, group, member, usemsgpack=True): + """ + Validate if a given member is in the SDL group. + + Parameters + ---------- + ns: string + SDL namespace + group: string + group name + member: + member to validate + usemsgpack: boolean (optional, default is True) + Determines whether the member is serialized using msgpack before storing. + If usemsgpack is True, the msgpack function `packb` is invoked + on the member to yield a byte array that is then sent to SDL. + Stated differently, if usemsgpack is True, the member can be anything + that is serializable by msgpack. + If usemsgpack is False, the member must be bytes. + + Returns + ------- + bool + True if member was in the group, false otherwise. + """ + if usemsgpack: + member = msgpack.packb(member, use_bin_type=True) + return self._sdl.is_member(ns, group, member) + + def group_size(self, ns, group): + """ + Return the number of members in a group. + If the group does not exist, value 0 is returned. + + Parameters + ---------- + ns: string + SDL namespace + group: string + group name to retrive size + usemsgpack: boolean (optional, default is True) + Determines whether the member is serialized using msgpack before storing. + If usemsgpack is True, the msgpack function `packb` is invoked + on the member to yield a byte array that is then sent to SDL. + Stated differently, if usemsgpack is True, the member can be anything + that is serializable by msgpack. + If usemsgpack is False, the member must be bytes. + + Returns + ------- + int + Number of members in a group. + """ + return self._sdl.group_size(ns, group) + + def set_and_publish(self, ns, channel, event, key, value, usemsgpack=True): + """ + Publish event to channel after writing data. + + Parameters + ---------- + ns: string + SDL namespace + channel: string + channel to publish event + event: string + published message + key: string + SDL key + value: + Object or byte array to store. See the `usemsgpack` parameter. + usemsgpack: boolean (optional, default is True) + Determines whether the value is serialized using msgpack before storing. + If usemsgpack is True, the msgpack function `packb` is invoked + on the value to yield a byte array that is then sent to SDL. + Stated differently, if usemsgpack is True, the value can be anything + that is serializable by msgpack. + If usemsgpack is False, the value must be bytes. + """ + if usemsgpack: + value = msgpack.packb(value, use_bin_type=True) + self._sdl.set_and_publish(ns, {channel: event}, {key: value}) + + def set_if_and_publish(self, ns, channel, event, key, old_value, new_value, usemsgpack=True): + """ + Publish event to channel after conditionally modifying the value of a key if the + current value in data storage matches the user's last known value. + + Parameters + ---------- + ns: string + SDL namespace + channel: string + channel to publish event + event: string + published message + key: string + SDL key + old_value: + Lask known object or byte array. See the `usemsgpack` parameter. + new_value: + Object or byte array to be written. See the `usemsgpack` parameter. + usemsgpack: boolean (optional, default is True) + Determines whether the old_value & new_value is serialized using msgpack before storing. + If usemsgpack is True, the msgpack function `packb` is invoked + on the old_value & new_value to yield a byte array that is then sent to SDL. + Stated differently, if usemsgpack is True, the old_value & new_value can be anything + that is serializable by msgpack. + If usemsgpack is False, the old_value & new_value must be bytes. + + Returns + ------- + bool + True for successful modification, false if the user's last known data did not + match the current value in data storage. + """ + if usemsgpack: + old_value = msgpack.packb(old_value, use_bin_type=True) + new_value = msgpack.packb(new_value, use_bin_type=True) + return self._sdl.set_if_and_publish(ns, {channel: event}, key, old_value, new_value) + + def set_if_not_exists_and_publish(self, ns, channel, event, key, value, usemsgpack=True): + """ + Publish event to channel after writing data to SDL storage if key does not exist. + + Parameters + ---------- + ns: string + SDL namespace + channel: string + channel to publish event + event: string + published message + key: string + SDL key + value: + Object or byte array to store. See the `usemsgpack` parameter. + usemsgpack: boolean (optional, default is True) + Determines whether the value is serialized using msgpack before storing. + If usemsgpack is True, the msgpack function `packb` is invoked + on the value to yield a byte array that is then sent to SDL. + Stated differently, if usemsgpack is True, the value can be anything + that is serializable by msgpack. + If usemsgpack is False, the value must be bytes. + + Returns + ------- + bool + True if key didn't exist yet and set operation was executed, false if key already + existed and thus its value was left untouched. + """ + if usemsgpack: + value = msgpack.packb(value, use_bin_type=True) + return self._sdl.set_if_not_exists_and_publish(ns, {channel: event}, key, value) + + def remove_and_publish(self, ns, channel, event, key): + """ + Publish event to channel after removing data. + + Parameters + ---------- + ns: string + SDL namespace + channel: string + channel to publish event + event: string + published message + key: string + SDL key + """ + self._sdl.remove_and_publish(ns, {channel: event}, {key}) + + def remove_if_and_publish(self, ns, channel, event, key, value, usemsgpack=True): + """ + Publish event to channel after removing key and its data from database if the + current data value is expected one. + + Parameters + ---------- + ns: string + SDL namespace + channel: string + channel to publish event + event: string + published message + key: string + SDL key + value: + Object or byte array to store. See the `usemsgpack` parameter. + usemsgpack: boolean (optional, default is True) + Determines whether the value is serialized using msgpack before storing. + If usemsgpack is True, the msgpack function `packb` is invoked + on the value to yield a byte array that is then sent to SDL. + Stated differently, if usemsgpack is True, the value can be anything + that is serializable by msgpack. + If usemsgpack is False, the value must be bytes. + + Returns + ------- + bool + True if successful removal, false if the user's last known data did not match the + current value in data storage. + """ + if usemsgpack: + value = msgpack.packb(value, use_bin_type=True) + return self._sdl.remove_if_and_publish(ns, {channel: event}, key, value) + + def remove_all_and_publish(self, ns, channel, event): + """ + Publish event to channel after removing all keys under the namespace. + + Parameters + ---------- + ns: string + SDL namespace + channel: string + channel to publish event + event: string + published message + """ + self._sdl.remove_all_and_publish(ns, {channel: event}) + + def subscribe_channel(self, ns, cb, channel): + """ + Subscribes the client to the specified channels. + + Parameters + ---------- + ns: string + SDL namespace + cb: + A function that is called when an event on channel is received. + channel: string + channel to subscribe + """ + self._sdl.subscribe_channel(ns, cb, {channel}) + + def unsubscribe_channel(self, ns, channel): + """ + unsubscribe_channel removes subscription from one or several channels. + + Parameters + ---------- + ns: string + SDL namespace + channel: string + channel to unsubscribe + """ + self._sdl.unsubscribe_channel(ns, {channel}) + + def start_event_listener(self): + """ + start_event_listener creates an event loop in a separate thread for handling + events from subscriptions. The registered callback function will be called + when an event is received. + """ + self._sdl.start_event_listener() + + def handle_events(self): + """ + handle_events is a non-blocking function that returns a tuple containing channel + name and message received from an event. The registered callback function will + still be called when an event is received. + + This function is called if SDL user decides to handle notifications in its own + event loop. Calling this function after start_event_listener raises an exception. + If there are no notifications, these returns None. + + Returns + ------- + Tuple: + (channel: str, message: str) + """ + return self._sdl.handle_events() + def healthcheck(self): """ Checks if the sdl connection is healthy. diff --git a/setup.py b/setup.py index dbd455e..6a0a862 100644 --- a/setup.py +++ b/setup.py @@ -32,12 +32,12 @@ def _long_descr(): setup( name="ricxappframe", - version="1.5.0", + version="1.6.0", packages=find_packages(exclude=["tests.*", "tests"]), author="O-RAN Software Community", description="Xapp and RMR framework for Python", url="https://gerrit.o-ran-sc.org/r/admin/repos/ric-plt/xapp-frame-py", - install_requires=["inotify_simple", "msgpack", "mdclogpy", "ricsdl>=2.0.3,<3.0.0"], + install_requires=["inotify_simple", "msgpack", "mdclogpy", "ricsdl>=2.1.0,<3.0.0"], classifiers=[ "Development Status :: 4 - Beta", "Intended Audience :: Telecommunications Industry", diff --git a/tests/test_sdl.py b/tests/test_sdl.py index c40960a..06c17c2 100644 --- a/tests/test_sdl.py +++ b/tests/test_sdl.py @@ -17,7 +17,7 @@ """ tests data functions """ - +import time from ricxappframe.xapp_sdl import SDLWrapper @@ -51,3 +51,187 @@ def test_sdl(): assert sdl.find_and_get(NS, "as.df") == {} assert sdl.find_and_get(NS, "") == {} + + +def test_sdl_set_get(): + """ + test set, get realted sdl methods + """ + sdl = SDLWrapper(use_fake_sdl=True) + + # set_if + sdl.set(NS, "gs.df1", "old") + assert sdl.get(NS, "gs.df1") == "old" + + sdl.set_if(NS, "gs.df1", "young", "new") + assert sdl.get(NS, "gs.df1") == "old" + + sdl.set_if(NS, "gs.df1", "old", "new") + assert sdl.get(NS, "gs.df1") == "new" + + # set_if_not_exists + sdl.set(NS, "gs.df2", "old") + assert sdl.get(NS, "gs.df2") == "old" + + sdl.set_if_not_exists(NS, "gs.df2", "new") + assert sdl.get(NS, "gs.df2") == "old" + + sdl.set_if_not_exists(NS, "gs.df3", "new") + assert sdl.get(NS, "gs.df3") == "new" + + # find_keys + assert sdl.find_keys(NS, "gs") == ["gs.df1", "gs.df2", "gs.df3"] + assert sdl.find_keys(NS, "gs.df1") == ["gs.df1"] + assert sdl.find_keys(NS, "gs.df2") == ["gs.df2"] + assert sdl.find_keys(NS, "gs.df3") == ["gs.df3"] + + # delete_if + sdl.set(NS, "gs.df4", "delete_this") + + assert sdl.delete_if(NS, "gs.df4", "delete") is False + assert sdl.delete_if(NS, "gs.df4", "delete_this") is True + assert sdl.get(NS, "gs.df4") is None + + +def test_sdl_member(): + """ + test member related sdl methods + """ + # add_member, remove_member, get_members + sdl = SDLWrapper(use_fake_sdl=True) + + sdl.add_member(NS, "group1", "member1") + assert sdl.is_member(NS, "group1", "member1") is True + + sdl.remove_member(NS, "group1", "not_member") + assert sdl.is_member(NS, "group1", "member1") is True + + sdl.remove_member(NS, "group1", "member1") + assert sdl.is_member(NS, "group1", "member1") is False + + # remove_group, group_size + sdl.add_member(NS, "group2", "member1") + sdl.add_member(NS, "group2", "member2") + assert sdl.group_size(NS, "group2") == 2 + sdl.remove_group(NS, "group2") + assert sdl.group_size(NS, "group2") == 0 + + # get_members + sdl.add_member(NS, "group3", "member1") + sdl.add_member(NS, "group3", "member2") + members = sdl.get_members(NS, "group3") + assert "member1" in members + assert "member2" in members + + +def test_sdl_set_and_publish_with_handle_events(): + """ + test set_and_publish* related sdl methods + """ + CH = "channel" + EVENT = "event" + CALLED = None + + def cb(channel, event): + nonlocal CH + nonlocal EVENT + nonlocal CALLED + # test is cb called + CALLED = True + assert channel == CH + assert event == EVENT + + sdl = SDLWrapper(use_fake_sdl=True) + sdl.subscribe_channel(NS, cb, "channel") + + # set_and_publish + CALLED = False + sdl.set_and_publish(NS, "channel", "event", "nt.df1", "old") + sdl.handle_events() + assert sdl.get(NS, "nt.df1") == "old" + assert CALLED is True + + # set_if_and_publish fail + CALLED = False + sdl.set_if_and_publish(NS, "channel", "event", "nt.df1", "young", "new") + sdl.handle_events() + assert sdl.get(NS, "nt.df1") == "old" + assert CALLED is False + # set_if_and_publish success + sdl.set_if_and_publish(NS, "channel", "event", "nt.df1", "old", "new") + sdl.handle_events() + assert sdl.get(NS, "nt.df1") == "new" + assert CALLED is True + + # set_if_not_exists_and_publish fail + CALLED = False + sdl.set_if_not_exists_and_publish(NS, "channel", "event", "nt.df1", "latest") + sdl.handle_events() + assert sdl.get(NS, "nt.df1") == "new" + assert CALLED is False + # set_if_not_exists_and_publish success + sdl.set_if_not_exists_and_publish( + NS, "channel", "event", "nt.df2", "latest") + sdl.handle_events() + assert sdl.get(NS, "nt.df2") == "latest" + assert CALLED is True + + sdl.unsubscribe_channel(NS, "channel") + + +def test_sdl_remove_and_publish_with_start_event_listener(): + """ + test remove_and_publish* related sdl methods + """ + CH = "channel" + EVENT = "event" + CALLED = None + + def cb(channel, event): + nonlocal CH + nonlocal EVENT + nonlocal CALLED + CALLED = True + assert channel == CH + assert event == EVENT + + sdl = SDLWrapper(use_fake_sdl=True) + sdl.subscribe_channel(NS, cb, "channel") + sdl.start_event_listener() + + # remove_and_publish success + CALLED = False + sdl.set(NS, "nt.df1", "old") + sdl.remove_and_publish(NS, "channel", "event", "nt.df1") + time.sleep(0.3) + assert sdl.get(NS, "nt.df1") is None + assert CALLED is True + + # remove_if_and_publish + CALLED = False + sdl.set(NS, "nt.df1", "old") + # fail + sdl.remove_if_and_publish(NS, "channel", "event", "nt.df1", "new") + time.sleep(0.3) + assert sdl.get(NS, "nt.df1") == "old" + assert CALLED is False + # success + sdl.remove_if_and_publish(NS, "channel", "event", "nt.df1", "old") + time.sleep(0.3) + assert sdl.get(NS, "nt.df1") is None + assert CALLED is True + + # remove_all_and_publish + CALLED = False + sdl.set(NS, "nt.df1", "data1") + sdl.set(NS, "nt.df2", "data2") + sdl.set(NS, "nt.df3", "data3") + sdl.remove_all_and_publish(NS, "channel", "event") + time.sleep(0.3) + assert sdl.get(NS, "nt.df1") is None + assert sdl.get(NS, "nt.df2") is None + assert sdl.get(NS, "nt.df3") is None + assert sdl.find_keys(NS, "*") == [] + assert CALLED is True + + sdl.unsubscribe_channel(NS, "channel")