From 43d07ee4e15a948f6d152d49ec8b585e9263c21b Mon Sep 17 00:00:00 2001 From: Martin Raspaud Date: Wed, 4 Mar 2026 13:02:47 +0100 Subject: [PATCH 1/4] Allow removing hidden files --- trollmoves/filescleaner.py | 10 ++- trollmoves/tests/test_remove_files.py | 91 ++++++++++++++++++++++++--- 2 files changed, 88 insertions(+), 13 deletions(-) diff --git a/trollmoves/filescleaner.py b/trollmoves/filescleaner.py index 0095718..0d3e275 100644 --- a/trollmoves/filescleaner.py +++ b/trollmoves/filescleaner.py @@ -39,6 +39,7 @@ def __init__(self, publisher, section, info, dry_run=True): self.info = info self.dry_run = dry_run self.recursive = self.info.get("recursive", False) + self.include_hidden = self.info.get("include_hidden", False) self.stat_time_method = self.info.get("stat_time_method", "st_ctime") def clean_dir(self, ref_time, pathname_template, **kwargs): @@ -49,16 +50,17 @@ def clean_dir(self, ref_time, pathname_template, **kwargs): LOGGER.info("Cleaning under %s", pathname_template) if not self.recursive: - filepaths = glob(pathname_template) + filepaths = glob(pathname_template, include_hidden=self.include_hidden) return self.clean_files_and_dirs(filepaths, ref_time) section_files = 0 section_size = 0 removed = [] - for pathname in glob(pathname_template): + for pathname in glob(pathname_template, include_hidden=self.include_hidden): for dirpath, _dirnames, _ in os.walk(Path(pathname).parent, followlinks=True): - files_in_dir = glob(os.path.join(dirpath, Path(pathname_template).name)) + files_in_dir = glob(os.path.join(dirpath, Path(pathname_template).name), + include_hidden=self.include_hidden) if len(files_in_dir) == 0: self._remove_empty_directory(dirpath) @@ -83,6 +85,8 @@ def clean_files_and_dirs(self, filepaths, ref_time): except OSError: LOGGER.warning("Couldn't stat path=%s", str(filepath)) continue + if filepath.endswith(".keep"): + continue if dt.datetime.fromtimestamp(getattr(stat, self.stat_time_method), tz=dt.timezone.utc) < ref_time: if not self.dry_run: diff --git a/trollmoves/tests/test_remove_files.py b/trollmoves/tests/test_remove_files.py index 6c5eae7..616dea1 100644 --- a/trollmoves/tests/test_remove_files.py +++ b/trollmoves/tests/test_remove_files.py @@ -257,18 +257,31 @@ def dummy_tree_of_some_files(request, tmp_path_factory) -> list[str]: os.utime(fn.parent, times=(atime, mtime)) filepaths.append(fn) + # create …/data/another_subdir/subsubdir2/subsubsub/dummy5.dat + # where …/data/another_subdir/subsubdir2 symlinks to …/data_to_link real_dir = tmp_path_factory.mktemp("data_to_link") - fn = basedir / "another_subdir" / "subsubdir2" - os.symlink(real_dir, fn) - # fn.mkdir() - symfn = fn / "dummy5.dat" - fn = real_dir / "dummy5.dat" + dir_name = basedir / "another_subdir" / "subsubdir2" + os.symlink(real_dir, dir_name) + symfn = dir_name / "subsubsub" / "dummy5.dat" + file5 = real_dir / "subsubsub" /"dummy5.dat" + file6 = real_dir / "subsubsub" /".dummy6.dat" + file5.parent.mkdir() + file5.write_text(DUMMY_CONTENT) + os.utime(file5, times=(atime, mtime)) + os.utime(file5.parent, times=(atime, mtime)) + file6.write_text(DUMMY_CONTENT) + os.utime(file6, times=(atime, mtime)) + os.utime(file6.parent, times=(atime, mtime)) + filepaths.append(symfn) + + # Make a sub-directory to keep: + fn = basedir / "subdir_to_keep" / ".keep" + fn.parent.mkdir() fn.write_text(DUMMY_CONTENT) os.utime(fn, times=(atime, mtime)) os.utime(fn.parent, times=(atime, mtime)) - filepaths.append(symfn) - + filepaths.append(fn) return filepaths @@ -398,14 +411,72 @@ def test_clean_follows_links(dummy_tree_of_some_files, tmp_path): "to": "some_users@xxx.yy", "subject": "Cleanup Error on {hostname}", "base_dir": f"{basedir}", - "templates": f"{basedir}/*", + "templates": "*", "stat_time_method": "st_mtime", "recursive": True, "hours": "1"} - fcleaner = FilesCleaner(pub, section, info, dry_run=True) + fcleaner = FilesCleaner(pub, section, info, dry_run=False) + + res = fcleaner.clean_section() + + section_size, section_files, removed_files = res + assert str(basedir / "another_subdir" / "subsubdir2" / "subsubsub" / "dummy5.dat") in removed_files + # non-empty subdir + assert (basedir / "another_subdir" / "subsubdir2" / "subsubsub").exists() + + +def test_clean_removes_hidden_files(dummy_tree_of_some_files, tmp_path): + """Test that cleaning follows links.""" + pub = FakePublisher() + list_of_files_to_clean = dummy_tree_of_some_files + + basedir = list_of_files_to_clean[0].parent + + section = "mytest_files1" + info = {"mailhost": "localhost", + "to": "some_users@xxx.yy", + "subject": "Cleanup Error on {hostname}", + "base_dir": f"{basedir}", + "templates": "*", + "stat_time_method": "st_mtime", + "recursive": True, + "include_hidden": True, + "hours": "1"} + + fcleaner = FilesCleaner(pub, section, info, dry_run=False) + + res = fcleaner.clean_section() + + section_size, section_files, removed_files = res + assert str(basedir / "another_subdir" / "subsubdir2" / "subsubsub" / ".dummy6.dat") in removed_files + # empty subdir, remove + assert not (basedir / "another_subdir" / "subsubdir2" / "subsubsub").exists() + # link, should not be removed + assert (basedir / "another_subdir" / "subsubdir2").exists() + + +def test_clean_keeps_keep_files(dummy_tree_of_some_files, tmp_path): + + pub = FakePublisher() + list_of_files_to_clean = dummy_tree_of_some_files + + basedir = list_of_files_to_clean[0].parent + + section = "mytest_files1" + info = {"mailhost": "localhost", + "to": "some_users@xxx.yy", + "subject": "Cleanup Error on {hostname}", + "base_dir": f"{basedir}", + "templates": "*", + "stat_time_method": "st_mtime", + "recursive": True, + "include_hidden": True, + "hours": "1"} + + fcleaner = FilesCleaner(pub, section, info, dry_run=False) res = fcleaner.clean_section() section_size, section_files, removed_files = res - assert str(basedir / "another_subdir" / "subsubdir2" / "dummy5.dat") in removed_files + assert (basedir / "subdir_to_keep").exists() From 25df4a2482f5a25915d490477ee248dcff509e30 Mon Sep 17 00:00:00 2001 From: Martin Raspaud Date: Wed, 4 Mar 2026 13:16:10 +0100 Subject: [PATCH 2/4] Add some documentation about remove_it --- docs/source/index.rst | 3 ++- docs/source/remove_it.rst | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 docs/source/remove_it.rst diff --git a/docs/source/index.rst b/docs/source/index.rst index 0d2a5c8..42892cf 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -14,6 +14,7 @@ These include: - Move_it_server and move_it_client - Trollstalker (Pytroll-watchers should be prefered if possible) - S3downloader + - Remove_it .. toctree:: @@ -22,7 +23,7 @@ These include: fetcher s3downloader - + remove_it Indices and tables ================== diff --git a/docs/source/remove_it.rst b/docs/source/remove_it.rst new file mode 100644 index 0000000..a9c0a62 --- /dev/null +++ b/docs/source/remove_it.rst @@ -0,0 +1,16 @@ +Remove_it +========= + +Remove_it is a script that is made to clean directories, and optionally publish messages about the removed files. + +An example config would look like:: + + [my_cleaning_job] + base_dir=/some/path/to/clean + templates=* + stat_time_method=st_mtime + recursive=true + include_hidden=false + +Even if "include_hidden" is set to "true", ".keep" files will never be removed (useful to avoid directories from being +cleaned up) From 776abaef133f1517efd615e146a3a24089bffd94 Mon Sep 17 00:00:00 2001 From: Martin Raspaud Date: Wed, 4 Mar 2026 13:19:22 +0100 Subject: [PATCH 3/4] Fix typo --- trollmoves/tests/test_remove_files.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/trollmoves/tests/test_remove_files.py b/trollmoves/tests/test_remove_files.py index 616dea1..09c2a00 100644 --- a/trollmoves/tests/test_remove_files.py +++ b/trollmoves/tests/test_remove_files.py @@ -264,8 +264,8 @@ def dummy_tree_of_some_files(request, tmp_path_factory) -> list[str]: dir_name = basedir / "another_subdir" / "subsubdir2" os.symlink(real_dir, dir_name) symfn = dir_name / "subsubsub" / "dummy5.dat" - file5 = real_dir / "subsubsub" /"dummy5.dat" - file6 = real_dir / "subsubsub" /".dummy6.dat" + file5 = real_dir / "subsubsub" / "dummy5.dat" + file6 = real_dir / "subsubsub" / ".dummy6.dat" file5.parent.mkdir() file5.write_text(DUMMY_CONTENT) os.utime(file5, times=(atime, mtime)) From a4a50fabdbe3b30bc271448ddc06a425bc56b42f Mon Sep 17 00:00:00 2001 From: Martin Raspaud Date: Wed, 4 Mar 2026 14:38:45 +0100 Subject: [PATCH 4/4] Fix s3downloader tests --- trollmoves/tests/test_s3downloader.py | 248 +++++++++++++------------- 1 file changed, 125 insertions(+), 123 deletions(-) diff --git a/trollmoves/tests/test_s3downloader.py b/trollmoves/tests/test_s3downloader.py index 4010388..94d67a7 100644 --- a/trollmoves/tests/test_s3downloader.py +++ b/trollmoves/tests/test_s3downloader.py @@ -1,12 +1,25 @@ """Test the s3downloader.""" import os +from contextlib import contextmanager from logging import StreamHandler from tempfile import NamedTemporaryFile -from unittest.mock import PropertyMock, patch +from unittest import mock +from unittest.mock import patch import pytest from posttroll.message import Message +from posttroll.testing import patched_publisher + + +@contextmanager +def _patched_subscriber_recv(messages): + """Like posttroll.testing.patched_subscriber_recv but accepts keyword arguments (e.g. timeout).""" + def recv(self, **kwargs): + yield from messages + + with mock.patch("posttroll.subscriber.Subscriber.recv", recv): + yield CONFIG_YAML = """ logging: @@ -120,14 +133,15 @@ def test_generate_message_if_file_does_not_exists_after_download(patch_os_path_e @patch("trollmoves.s3downloader.S3Downloader._download_from_s3") @patch("trollmoves.s3downloader.S3Downloader._get_basename") -@patch("queue.Queue") -def test_get_one_message(patch_subscribe, patch_get_basename, patch_download_from_s3, s3dl): +def test_get_one_message(patch_get_basename, patch_download_from_s3, s3dl): + import queue + s3dl.read_config() s3dl.setup_logging() to_send = {"some_key": "with_a_value", "uri": "now-this-is-a-uri"} msg = Message("/publish-topic", "file", to_send) - s3dl.listener_queue = patch_subscribe - s3dl.listener_queue.get.return_value = msg + s3dl.listener_queue = queue.Queue() + s3dl.listener_queue.put(msg) patch_get_basename.return_value = "filename-basename" patch_download_from_s3.return_value = True result = s3dl._get_one_message() @@ -136,40 +150,43 @@ def test_get_one_message(patch_subscribe, patch_get_basename, patch_download_fro @patch("trollmoves.s3downloader.S3Downloader._download_from_s3") @patch("trollmoves.s3downloader.S3Downloader._get_basename") -@patch("queue.Queue") -def test_get_one_message_none(patch_sub_q, patch_get_basename, patch_download_from_s3, s3dl): +def test_get_one_message_none(patch_get_basename, patch_download_from_s3, s3dl): + import queue + s3dl.read_config() s3dl.setup_logging() - s3dl.listener_queue = patch_sub_q - s3dl.listener_queue.get.return_value = None - patch_get_basename.return_value = "filename-basename" - patch_download_from_s3.return_value = True + s3dl.listener_queue = queue.Queue() + s3dl.listener_queue.put(None) result = s3dl._get_one_message() assert result is True @patch("trollmoves.s3downloader.S3Downloader._download_from_s3") @patch("trollmoves.s3downloader.S3Downloader._get_basename") -@patch("queue.Queue") -def test_get_one_message_download_false(patch_sub_q, patch_get_bn, patch_dl_s3, caplog, s3dl): +def test_get_one_message_download_false(patch_get_bn, patch_dl_s3, caplog, s3dl): import logging + import queue + s3dl.read_config() s3dl.setup_logging() patch_get_bn.return_value = "filename-basename" patch_dl_s3.return_value = False caplog.set_level(logging.DEBUG) - s3dl.listener_queue = patch_sub_q + s3dl.listener_queue = queue.Queue() + s3dl.listener_queue.put(Message("/publish-topic", "file", {"uri": "some-uri"})) result = s3dl._get_one_message() assert "Could not download file filename-basename for some reason. SKipping this." in caplog.text assert result is True -@patch("queue.Queue") -def test_get_one_message_keyboardinterrupt(patch_subscribe, s3dl): +def test_get_one_message_keyboardinterrupt(s3dl): + class _KeyboardInterruptQueue: + def get(self, **kwargs): + raise KeyboardInterrupt + s3dl.read_config() s3dl.setup_logging() - s3dl.listener_queue = patch_subscribe - s3dl.listener_queue.get.side_effect = KeyboardInterrupt + s3dl.listener_queue = _KeyboardInterruptQueue() result = s3dl._get_one_message() assert result is False @@ -245,144 +262,140 @@ def test_setup_logging_file(config_yaml): assert handler.backupCount == 30 -@patch("posttroll.publisher.Publish") -@patch("queue.Queue") -def test_file_publisher_init(patch_publish_queue, patch_publish): +def test_file_publisher_init(): + import queue + from trollmoves.s3downloader import FilePublisher nameservers = None - fp = FilePublisher(patch_publish_queue, nameservers) + pqueue = queue.Queue() + fp = FilePublisher(pqueue, nameservers) assert fp.loop is True assert fp.service_name == "s3downloader" assert fp.nameservers == nameservers - assert fp.queue == patch_publish_queue + assert fp.queue is pqueue MSG_1 = Message("/topic", "file", data={"uid": "file1"}) -@patch("trollmoves.s3downloader.Publish") -@patch("queue.Queue") -def test_file_publisher_break(patch_publish_queue, patch_publish): +def test_file_publisher_break(): + import queue + from trollmoves.s3downloader import FilePublisher - nameservers = None - patch_publish_queue.get = PropertyMock(side_effect=[[MSG_1.encode(), None], ]) - fp = FilePublisher(patch_publish_queue, nameservers) + fp = FilePublisher(queue.Queue(), None) fp.loop = False - fp.run() - patch_publish().__enter__().send.assert_not_called() + with patched_publisher() as published: + fp.run() + assert published == [] -@patch("trollmoves.s3downloader.Publish") -@patch("queue.Queue") -def test_file_publisher_publish_message(patch_publish_queue, patch_publish): - from trollmoves.s3downloader import FilePublisher - nameservers = None - patch_publish_queue.get = PropertyMock(side_effect=[[MSG_1.encode()]]) - fp = FilePublisher(patch_publish_queue, nameservers) - fp._publish_message(patch_publish) - patch_publish.send.assert_called_once() +def test_file_publisher_publish_message(): + import queue + from trollmoves.s3downloader import FilePublisher, Publish + pqueue = queue.Queue() + pqueue.put(MSG_1.encode()) + fp = FilePublisher(pqueue, None) + with patched_publisher() as published: + with Publish("s3downloader", nameservers=None) as publisher: + fp._publish_message(publisher) + assert published == [MSG_1.encode()] -@patch("trollmoves.s3downloader.Publish") -@patch("queue.Queue") -def test_file_publisher_message_is_none(patch_publish_queue, patch_publish): - from trollmoves.s3downloader import FilePublisher - nameservers = None - patch_publish_queue.get = PropertyMock(side_effect=[None, ]) - fp = FilePublisher(patch_publish_queue, nameservers) - fp._publish_message(patch_publish) - patch_publish.send.assert_not_called() +def test_file_publisher_message_is_none(): + import queue -@patch("trollmoves.s3downloader.Publish") -def test_file_publisher_stop_loop(patch_publish): + from trollmoves.s3downloader import FilePublisher, Publish + pqueue = queue.Queue() + pqueue.put(None) + fp = FilePublisher(pqueue, None) + with patched_publisher() as published: + with Publish("s3downloader", nameservers=None) as publisher: + fp._publish_message(publisher) + assert published == [] + + +def test_file_publisher_stop_loop(): import queue from trollmoves.s3downloader import FilePublisher - nameservers = None - pqueue = queue.Queue() - fp = FilePublisher(pqueue, nameservers) + fp = FilePublisher(queue.Queue(), None) fp.stop() assert fp.loop is False -@patch("trollmoves.s3downloader.Publish") -@patch("queue.Queue") -def test_file_publisher_queue_timeout(patch_publish_queue, patch_publish): +def test_file_publisher_queue_timeout(): import queue - from trollmoves.s3downloader import FilePublisher - nameservers = None - patch_publish_queue.get.side_effect = queue.Empty - fp = FilePublisher(patch_publish_queue, nameservers) - fp._publish_message(patch_publish) - patch_publish.send.assert_not_called() + from trollmoves.s3downloader import FilePublisher, Publish + fp = FilePublisher(queue.Queue(), None) # empty queue → get() times out + with patched_publisher() as published: + with Publish("s3downloader", nameservers=None) as publisher: + fp._publish_message(publisher) + assert published == [] -@patch("trollmoves.s3downloader.Publish") -@patch("queue.Queue") -def test_file_publisher_exception_1(patch_publish_queue, patch_publish): +def test_file_publisher_exception_1(): + + class _KeyboardInterruptQueue: + def get(self, **kwargs): + raise KeyboardInterrupt + from trollmoves.s3downloader import FilePublisher - nameservers = None - patch_publish_queue.get.side_effect = KeyboardInterrupt - fp = FilePublisher(patch_publish_queue, nameservers) - with pytest.raises(KeyboardInterrupt): - fp.run() + fp = FilePublisher(_KeyboardInterruptQueue(), None) + with patched_publisher(): + with pytest.raises(KeyboardInterrupt): + fp.run() posttroll_config = {"subscribe-topic": "/yuhu"} -@patch("queue.Queue") -def test_listener_init(patch_listener_queue): +def test_listener_init(): + import queue + from trollmoves.s3downloader import Listener - subscribe_nameserver = "localhost" - listenr = Listener(patch_listener_queue, posttroll_config, subscribe_nameserver) + lqueue = queue.Queue() + listenr = Listener(lqueue, posttroll_config, "localhost") assert listenr.loop is True - assert listenr.queue == patch_listener_queue + assert listenr.queue is lqueue assert listenr.config == posttroll_config - assert listenr.subscribe_nameserver == subscribe_nameserver + assert listenr.subscribe_nameserver == "localhost" -@patch("posttroll.subscriber.Subscriber") -@patch("posttroll.subscriber.get_pub_address") -def test_listener_message(patch_get_pub_address, patch_subscriber, caplog): +def test_listener_message(caplog): """Test listener push message.""" import logging import queue from trollmoves.s3downloader import Listener - subscribe_nameserver = "localhost" caplog.set_level(logging.DEBUG) - patch_subscriber.return_value.recv = PropertyMock(side_effect=[[MSG_1, None], ]) lqueue = queue.Queue() - listener = Listener(lqueue, posttroll_config, subscribe_nameserver) - listener.run() + listener = Listener(lqueue, {**posttroll_config, "subscriber_addresses": "ipc://bla"}, False) + with _patched_subscriber_recv([MSG_1, None]): + listener.run() - assert "Starting FileListener." in caplog.text assert lqueue.qsize() == 1 message = lqueue.get() assert message.type == "file" -@patch("posttroll.subscriber.Subscriber") -@patch("posttroll.subscriber.get_pub_address") -@patch("queue.Queue") -def test_listener_message_break(patch_listener_queue, patch_get_pub_address, patch_subscriber, caplog): +def test_listener_message_break(caplog): """Test listener push message.""" import logging + import queue from trollmoves.s3downloader import Listener caplog.set_level(logging.DEBUG) - subscribe_nameserver = "localhost" - patch_subscriber.return_value.recv = PropertyMock(side_effect=[[MSG_1, None], ]) - listener = Listener(patch_listener_queue, posttroll_config, subscribe_nameserver) + lqueue = queue.Queue() + listener = Listener(lqueue, {**posttroll_config, "subscriber_addresses": "ipc://bla"}, False) listener.loop = False - listener.run() - patch_listener_queue().put.assert_not_called() + with _patched_subscriber_recv([MSG_1, None]): + listener.run() + assert lqueue.qsize() == 0 MSG_ACK = Message("/topic", "ack", data={"uid": "file1"}) @@ -418,37 +431,30 @@ def test_listener_message_stop(): assert message is None -@patch("posttroll.subscriber.Subscriber") -@patch("posttroll.subscriber.get_pub_address") -def test_listener_message_check_config(patch_get_pub_address, patch_subscriber): +def test_listener_message_check_config(): """Test listener push message.""" import queue from trollmoves.s3downloader import Listener - posttroll_config["subscribe-topic"] = "is-a-string-topic" - posttroll_config["subscriber_addresses"] = "first_address, second_address" - subscribe_nameserver = "localhost" - + config = {**posttroll_config, "subscribe-topic": "is-a-string-topic", "subscriber_addresses": "ipc://bla"} lqueue = queue.Queue() - listener = Listener(lqueue, posttroll_config, subscribe_nameserver) - listener.run() + listener = Listener(lqueue, config, False) + with _patched_subscriber_recv([]): + listener.run() assert isinstance(listener.config["subscribe-topic"], list) is True assert listener.config["services"] == "" -@patch("posttroll.subscriber.Subscriber") -@patch("posttroll.subscriber.get_pub_address") -def test_listener_message_check_message_and_put(patch_get_pub_address, patch_subscriber): +def test_listener_message_check_message_and_put(): """Test listener push message.""" import queue from trollmoves.s3downloader import Listener - posttroll_config["subscribe-topic"] = "is-a-string-topic" - posttroll_config["subscriber_addresses"] = "first_address, second_address" - subscribe_nameserver = "localhost" - + config = {**posttroll_config, + "subscribe-topic": "is-a-string-topic", + "subscriber_addresses": "first_address, second_address"} lqueue = queue.Queue() - listener = Listener(lqueue, posttroll_config, subscribe_nameserver) + listener = Listener(lqueue, config, False) assert listener._check_and_put_message_to_queue(MSG_1) is True assert listener._check_and_put_message_to_queue(None) is True @@ -456,32 +462,28 @@ def test_listener_message_check_message_and_put(patch_get_pub_address, patch_sub assert listener._check_and_put_message_to_queue(MSG_1) is False -@patch("posttroll.subscriber.Subscriber") -@patch("posttroll.subscriber.get_pub_address") -def test_listener_message_exception_1(patch_get_pub_address, patch_subscriber): +@patch("trollmoves.s3downloader.Subscribe") +def test_listener_message_exception_1(patch_subscribe): """Test listener push message.""" import queue from trollmoves.s3downloader import Listener - subscribe_nameserver = "localhost" lqueue = queue.Queue() - listener = Listener(lqueue, posttroll_config, subscribe_nameserver) - patch_subscriber.side_effect = KeyError + listener = Listener(lqueue, posttroll_config, "localhost") + patch_subscribe.side_effect = KeyError with pytest.raises(KeyError): listener.run() -@patch("posttroll.subscriber.Subscriber") -@patch("posttroll.subscriber.get_pub_address") -def test_listener_message_exception_2(patch_get_pub_address, patch_subscriber): +@patch("trollmoves.s3downloader.Subscribe") +def test_listener_message_exception_2(patch_subscribe): """Test listener push message.""" import queue from trollmoves.s3downloader import Listener - subscribe_nameserver = "localhost" lqueue = queue.Queue() - listener = Listener(lqueue, posttroll_config, subscribe_nameserver) - patch_subscriber.side_effect = KeyboardInterrupt + listener = Listener(lqueue, posttroll_config, "localhost") + patch_subscribe.side_effect = KeyboardInterrupt with pytest.raises(KeyboardInterrupt): listener.run()