diff --git a/i18n.py b/i18n.py index 3ce777f..dd70837 100644 --- a/i18n.py +++ b/i18n.py @@ -101,30 +101,22 @@ TR = { "Unexpected error: {error}": "Beklenmeyen hata: {error}", # --- audio / paste errors ----------------------------------------- - "pw-record not found. Is pipewire-audio installed?": - "pw-record bulunamadı. pipewire-audio kurulu mu?", "Could not start recording: {error}": "Kayıt başlatılamadı: {error}", "No audio recorder found. Install pulseaudio-utils or pipewire-audio.": "Ses kayıt aracı bulunamadı. pulseaudio-utils ya da pipewire-audio kur.", "Audio recorder stopped before receiving sound: {error}": "Ses kayıt aracı veri alamadan kapandı: {error}", - "wl-copy not found. Install wl-clipboard.": - "wl-copy bulunamadı. wl-clipboard paketini kur.", "Could not copy to clipboard: {error}": "Panoya kopyalanamadı: {error}", - "{tool} not found; clipboard copy is unavailable.": - "{tool} bulunamadı; panoya kopyalama kullanılamıyor.", + "{tool} not found. Install {packages}.": + "{tool} bulunamadı. {packages} paketlerini kur.", "{tool} exited with code {code}.": "{tool} {code} koduyla çıktı.", "{tool} not found, cannot paste automatically.": "{tool} bulunamadı, otomatik yapıştırma yapılamıyor.", - "Could not run xdotool: {error}": "xdotool çalıştırılamadı: {error}", - "xdotool failed: {error}": "xdotool hatası: {error}", - "wl-copy exited with code {code}.": "wl-copy {code} koduyla çıktı.", - "ydotool not found, cannot paste automatically.": - "ydotool bulunamadı, otomatik yapıştırma yapılamıyor.", "Unknown key: {key}": "Bilinmeyen tuş: {key}", - "Could not run ydotool: {error}": "ydotool çalıştırılamadı: {error}", - "ydotool failed: {error}\nIs ydotoold running? (systemctl --user status ydotool)": - "ydotool hatası: {error}\nydotoold çalışıyor mu? (systemctl --user status ydotool)", + "Could not run {tool}: {error}": "{tool} çalıştırılamadı: {error}", + "{tool} failed: {error}": "{tool} hatası: {error}", + "Is ydotoold running? (systemctl --user status ydotool)": + "ydotoold çalışıyor mu? (systemctl --user status ydotool)", # --- api errors ---------------------------------------------------- "{service} API key is empty. Add it in Settings.": diff --git a/paste.py b/paste.py index b87d71d..29b3a5b 100644 --- a/paste.py +++ b/paste.py @@ -1,5 +1,13 @@ -"""Clipboard and key injection for Wayland and X11.""" +"""Clipboard and key injection, through whichever pair of programs is here. +A Wayland session has wl-clipboard and ydotool, an X11 one has xclip and +xdotool, and a session is one or the other. Which it is gets decided in one +place, and each desktop is a small group of functions below it: another desktop, +or another operating system, adds a group and a line to the chooser rather than +a branch inside every function here. +""" + +import collections import os import shutil import subprocess @@ -7,35 +15,105 @@ import time from i18n import t -# Linux input event codes (linux/input-event-codes.h) +# Linux input event codes (linux/input-event-codes.h), which is what ydotool +# takes. They are also the list of keys a paste shortcut may be built from, so +# xdotool is held to the same table rather than being handed the text as typed. KEYCODES = { "ctrl": 29, "control": 29, "shift": 42, "alt": 56, "super": 125, "meta": 125, "v": 47, "insert": 110, "enter": 28, "return": 28, } +# xdotool speaks X keysyms, which spell some of those differently. +KEYSYMS = {"control": "ctrl", "meta": "super", "insert": "Insert", + "enter": "Return", "return": "Return"} + class PasteError(Exception): pass +def _keys(shortcut): + """'Ctrl+V' -> ['ctrl', 'v'], every one of them a key we know.""" + parts = [key.strip().lower() for key in str(shortcut).split("+") if key.strip()] + for key in parts: + if key not in KEYCODES: + raise PasteError(t("Unknown key: {key}", key=key)) + return parts + + +def _ydotool_command(shortcut): + """ydotool wants a press event per key, then a release in reverse.""" + codes = [KEYCODES[key] for key in _keys(shortcut)] + return ["ydotool", "key", *[f"{code}:1" for code in codes], + *[f"{code}:0" for code in reversed(codes)]] + + +def _xdotool_command(shortcut): + """xdotool takes the whole combination as one argument.""" + keys = [KEYSYMS.get(key, key) for key in _keys(shortcut)] + return ["xdotool", "key", "--clearmodifiers", "+".join(keys)] + + +Desktop = collections.namedtuple( + "Desktop", + # The two programs, the packages to install them from, how to build the key + # press, and what else to say when the key press fails. + "clipboard keyboard packages read_command copy_command key_command key_hint", +) + +WAYLAND = Desktop( + clipboard="wl-copy", + keyboard="ydotool", + packages="wl-clipboard and ydotool", + read_command=["wl-paste", "--no-newline"], + copy_command=["wl-copy"], + key_command=_ydotool_command, + key_hint="Is ydotoold running? (systemctl --user status ydotool)", +) + +X11 = Desktop( + clipboard="xclip", + keyboard="xdotool", + packages="xclip and xdotool", + read_command=["xclip", "-selection", "clipboard", "-out"], + copy_command=["xclip", "-selection", "clipboard", "-in"], + key_command=_xdotool_command, + key_hint="", +) + + +def desktop(): + """The pair of programs this session's clipboard and keyboard go through. + + Read every time rather than settled at import: a session started before the + display server was up would otherwise be stuck with the wrong answer, and a + test would have nowhere to say which one it means. + """ + if os.environ.get("XDG_SESSION_TYPE") == "x11": + return X11 + if os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): + return X11 + return WAYLAND + + +# --- the clipboard --------------------------------------------------------- + def read_clipboard(): - command = (["xclip", "-selection", "clipboard", "-out"] if _x11() - else ["wl-paste", "--no-newline"]) - if not shutil.which(command[0]): + here = desktop() + if not shutil.which(here.read_command[0]): return None try: - res = subprocess.run(command, capture_output=True, timeout=5) + res = subprocess.run(here.read_command, capture_output=True, timeout=5) except (subprocess.SubprocessError, OSError): return None return res.stdout if res.returncode == 0 else None def _run_copy(payload): - """The clipboard owner may fork; do not leave inherited pipes open.""" - command = (["xclip", "-selection", "clipboard", "-in"] if _x11() - else ["wl-copy"]) + """The clipboard owner forks to keep holding the selection; leaving its + pipes open makes subprocess.run wait for EOF forever, hence DEVNULL.""" return subprocess.run( - command, + desktop().copy_command, input=payload, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, @@ -44,21 +122,21 @@ def _run_copy(payload): def copy(text): - tool = "xclip" if _x11() else "wl-copy" - if not shutil.which(tool): - raise PasteError(t("{tool} not found; clipboard copy is unavailable.", tool=tool)) + here = desktop() + if not shutil.which(here.clipboard): + raise PasteError(t("{tool} not found. Install {packages}.", + tool=here.clipboard, packages=here.packages)) try: res = _run_copy(text.encode("utf-8")) except (subprocess.SubprocessError, OSError) as exc: raise PasteError(t("Could not copy to clipboard: {error}", error=exc)) from exc if res.returncode != 0: raise PasteError(t("{tool} exited with code {code}.", - tool=tool, code=res.returncode)) + tool=here.clipboard, code=res.returncode)) def copy_bytes(data): - tool = "xclip" if _x11() else "wl-copy" - if data is None or not shutil.which(tool): + if data is None or not shutil.which(desktop().clipboard): return try: _run_copy(data) @@ -66,54 +144,28 @@ def copy_bytes(data): pass -def ydotool_ready(): - tool = "xdotool" if _x11() else "ydotool" - return shutil.which(tool) is not None +# --- the key press --------------------------------------------------------- - -def _x11(): - return (os.environ.get("XDG_SESSION_TYPE") == "x11" - or bool(os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"))) +def paste_ready(): + return shutil.which(desktop().keyboard) is not None def press(shortcut="ctrl+v", delay=0.12): - """Press a key combination through xdotool or ydotool.""" - if not ydotool_ready(): - tool = "xdotool" if _x11() else "ydotool" - raise PasteError(t("{tool} not found, cannot paste automatically.", tool=tool)) + """Press a key combination, e.g. 'ctrl+v'.""" + here = desktop() + if not paste_ready(): + raise PasteError(t("{tool} not found, cannot paste automatically.", + tool=here.keyboard)) - if _x11(): - key = shortcut.lower().replace("control", "ctrl") - time.sleep(delay) - try: - res = subprocess.run( - ["xdotool", "key", "--clearmodifiers", key], - capture_output=True, text=True, timeout=10, - ) - except (subprocess.SubprocessError, OSError) as exc: - raise PasteError(t("Could not run xdotool: {error}", error=exc)) from exc - if res.returncode != 0: - raise PasteError(t("xdotool failed: {error}", - error=res.stderr.strip() or "unknown error")) - return - - codes = [] - for key in (k.strip().lower() for k in shortcut.split("+") if k.strip()): - code = KEYCODES.get(key) - if code is None: - raise PasteError(t("Unknown key: {key}", key=key)) - codes.append(code) - - seq = [f"{c}:1" for c in codes] + [f"{c}:0" for c in reversed(codes)] + command = here.key_command(shortcut) time.sleep(delay) # let the selection settle and focus come back try: - res = subprocess.run(["ydotool", "key", *seq], capture_output=True, - text=True, timeout=10) + res = subprocess.run(command, capture_output=True, text=True, timeout=10) except (subprocess.SubprocessError, OSError) as exc: - raise PasteError(t("Could not run ydotool: {error}", error=exc)) from exc + raise PasteError(t("Could not run {tool}: {error}", + tool=here.keyboard, error=exc)) from exc if res.returncode != 0: - raise PasteError(t( - "ydotool failed: {error}\nIs ydotoold running? " - "(systemctl --user status ydotool)", - error=res.stderr.strip() or "unknown error", - )) + message = t("{tool} failed: {error}", tool=here.keyboard, + error=res.stderr.strip() or "unknown error") + raise PasteError(f"{message}\n{t(here.key_hint)}" if here.key_hint + else message) diff --git a/tests/test_desktop_compat.py b/tests/test_desktop_compat.py deleted file mode 100644 index 150944b..0000000 --- a/tests/test_desktop_compat.py +++ /dev/null @@ -1,55 +0,0 @@ -import os -import unittest -from unittest import mock - -import audio -import hotkey -import paste - - -class AudioBackendTests(unittest.TestCase): - def test_parec_is_preferred(self): - with mock.patch.object(audio.shutil, "which", side_effect=lambda cmd: f"/usr/bin/{cmd}"): - self.assertEqual(audio.recording_command()[0], "parec") - - def test_pw_record_remains_the_fallback(self): - with mock.patch.object( - audio.shutil, "which", side_effect=lambda cmd: "/usr/bin/pw-record" - if cmd == "pw-record" else None, - ): - self.assertEqual(audio.recording_command()[0], "pw-record") - - -class DesktopBackendTests(unittest.TestCase): - def test_x11_uses_xclip(self): - result = mock.Mock(returncode=0) - with mock.patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}), \ - mock.patch.object(paste.shutil, "which", return_value="/usr/bin/xclip"), \ - mock.patch.object(paste.subprocess, "run", return_value=result) as run: - paste.copy("hello") - self.assertEqual(run.call_args.args[0][:3], - ["xclip", "-selection", "clipboard"]) - - def test_x11_uses_xdotool(self): - result = mock.Mock(returncode=0, stderr="") - with mock.patch.dict(os.environ, {"XDG_SESSION_TYPE": "x11"}), \ - mock.patch.object(paste.shutil, "which", return_value="/usr/bin/xdotool"), \ - mock.patch.object(paste.time, "sleep"), \ - mock.patch.object(paste.subprocess, "run", return_value=result) as run: - paste.press("ctrl+shift+v") - self.assertEqual(run.call_args.args[0], - ["xdotool", "key", "--clearmodifiers", "ctrl+shift+v"]) - - -class GnomeShortcutTests(unittest.TestCase): - def test_accelerator_round_trip(self): - accelerator = hotkey.gnome_accelerator("Ctrl+Alt+A") - self.assertEqual(accelerator, "a") - self.assertEqual(hotkey.display_accelerator(accelerator), "Ctrl+Alt+A") - - def test_empty_gsettings_array(self): - self.assertEqual(hotkey._gsettings_array("@as []"), []) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_paste.py b/tests/test_paste.py index 96c258d..1d6806b 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -1,12 +1,18 @@ """The clipboard and the key press, which is where a dictation actually lands. Everything here shells out, so the tools are faked. What the tests hold onto is -the command line: a paste that presses the wrong codes, or in the wrong order, +the command line: a paste that presses the wrong keys, or in the wrong order, types nothing and looks like a hang. + +Both desktops owe the same promises, so those are written once and run against +each of them. A third one added to paste.py inherits the same list rather than +needing its own copy of it. """ +import os import subprocess import unittest +from typing import ClassVar from unittest import mock import paste @@ -14,47 +20,86 @@ from tests.support import DikteTest, FakeCompleted, linux_only, only_these_tools @linux_only -class ReadClipboard(DikteTest): - def test_no_wl_paste_installed(self): +class Chooser(DikteTest): + """Which pair of programs this session's clipboard goes through.""" + + def under(self, **env): + with mock.patch.dict(os.environ, env, clear=True): + return paste.desktop() + + def test_a_wayland_session(self): + self.assertIs(self.under(XDG_SESSION_TYPE="wayland", + WAYLAND_DISPLAY="wayland-0"), paste.WAYLAND) + + def test_an_x11_session(self): + self.assertIs(self.under(XDG_SESSION_TYPE="x11", DISPLAY=":0"), paste.X11) + + def test_a_display_with_no_wayland_beside_it(self): + self.assertIs(self.under(DISPLAY=":0"), paste.X11) + + def test_an_x11_display_under_wayland_is_still_wayland(self): + """XWayland sets DISPLAY too; the session type is the one to believe.""" + self.assertIs(self.under(XDG_SESSION_TYPE="wayland", + DISPLAY=":0", WAYLAND_DISPLAY="wayland-0"), + paste.WAYLAND) + + def test_nothing_set_at_all(self): + self.assertIs(self.under(), paste.WAYLAND) + + +class DesktopContract: + """What both desktops owe. Each of them subclasses this once, below.""" + + env: ClassVar[dict] = {} + here = None + + def setUp(self): + super().setUp() + self.enterContext(mock.patch.dict(os.environ, self.env, clear=True)) + + # ---- reading the clipboard ------------------------------------------- + + def test_no_reader_installed(self): with only_these_tools(): self.assertIsNone(paste.read_clipboard()) def test_what_is_on_the_clipboard_comes_back_as_bytes(self): - with only_these_tools("wl-paste"), \ + with only_these_tools(self.here.read_command[0]), \ mock.patch.object(subprocess, "run", return_value=FakeCompleted(stdout=b"hello")) as run: self.assertEqual(paste.read_clipboard(), b"hello") - self.assertEqual(run.call_args.args[0], ["wl-paste", "--no-newline"]) + self.assertEqual(run.call_args.args[0], self.here.read_command) def test_an_empty_clipboard_is_not_an_error(self): - with only_these_tools("wl-paste"), \ + with only_these_tools(self.here.read_command[0]), \ mock.patch.object(subprocess, "run", return_value=FakeCompleted(returncode=1)): self.assertIsNone(paste.read_clipboard()) - def test_a_tool_that_will_not_run(self): - with only_these_tools("wl-paste"), \ + def test_a_reader_that_will_not_run(self): + with only_these_tools(self.here.read_command[0]), \ mock.patch.object(subprocess, "run", side_effect=OSError("nope")): self.assertIsNone(paste.read_clipboard()) + # ---- copying ---------------------------------------------------------- -@linux_only -class Copy(DikteTest): - def test_no_wl_copy_installed(self): + def test_no_clipboard_tool_installed_says_what_to_install(self): with only_these_tools(), self.assertRaises(paste.PasteError) as caught: paste.copy("hello") - self.assertIn("wl-clipboard", str(caught.exception)) + self.assertIn(self.here.clipboard, str(caught.exception)) + self.assertIn(self.here.packages.split(" and ")[0], str(caught.exception)) def test_the_text_goes_in_as_utf8(self): - with only_these_tools("wl-copy"), \ + with only_these_tools(self.here.clipboard), \ mock.patch.object(subprocess, "run", return_value=FakeCompleted()) as run: paste.copy("günaydın") + self.assertEqual(run.call_args.args[0], self.here.copy_command) self.assertEqual(run.call_args.kwargs["input"], "günaydın".encode()) def test_the_pipes_are_closed_so_the_call_can_return(self): - """wl-copy forks and holds the selection; a pipe nobody drains hangs.""" - with only_these_tools("wl-copy"), \ + """The clipboard owner forks; a pipe nobody drains hangs the caller.""" + with only_these_tools(self.here.clipboard), \ mock.patch.object(subprocess, "run", return_value=FakeCompleted()) as run: paste.copy("hello") @@ -62,95 +107,130 @@ class Copy(DikteTest): self.assertEqual(run.call_args.kwargs["stderr"], subprocess.DEVNULL) def test_a_non_zero_exit_is_reported(self): - with only_these_tools("wl-copy"), \ + with only_these_tools(self.here.clipboard), \ mock.patch.object(subprocess, "run", return_value=FakeCompleted(returncode=1)), \ self.assertRaises(paste.PasteError): paste.copy("hello") - def test_a_tool_that_will_not_run(self): - with only_these_tools("wl-copy"), \ + def test_a_clipboard_tool_that_will_not_run(self): + with only_these_tools(self.here.clipboard), \ mock.patch.object(subprocess, "run", side_effect=OSError("nope")), \ self.assertRaises(paste.PasteError): paste.copy("hello") - -@linux_only -class CopyBytes(DikteTest): - def test_nothing_to_restore(self): - with only_these_tools("wl-copy"), \ + def test_there_is_nothing_to_restore(self): + with only_these_tools(self.here.clipboard), \ mock.patch.object(subprocess, "run") as run: paste.copy_bytes(None) run.assert_not_called() def test_restoring_never_raises(self): """It runs after the paste went in; failing here must not undo that.""" - with only_these_tools("wl-copy"), \ + with only_these_tools(self.here.clipboard), \ mock.patch.object(subprocess, "run", side_effect=OSError("nope")): paste.copy_bytes(b"whatever was there before") def test_the_bytes_go_back_untouched(self): - with only_these_tools("wl-copy"), \ + with only_these_tools(self.here.clipboard), \ mock.patch.object(subprocess, "run", return_value=FakeCompleted()) as run: paste.copy_bytes(b"\x89PNG\r\n") self.assertEqual(run.call_args.kwargs["input"], b"\x89PNG\r\n") + # ---- pressing the key ------------------------------------------------- -@linux_only -class Press(DikteTest): - def setUp(self): - super().setUp() - # The settle delay is real time nobody needs to spend in a test. - self.patch_attr(paste.time, "sleep", lambda seconds: None) - - def run_press(self, shortcut, result=None): - with only_these_tools("ydotool"), \ + def press(self, shortcut, result=None): + with only_these_tools(self.here.keyboard), \ + mock.patch.object(paste.time, "sleep", lambda seconds: None), \ mock.patch.object(subprocess, "run", return_value=result or FakeCompleted()) as run: paste.press(shortcut) return run.call_args.args[0] - def test_no_ydotool_installed(self): + def test_no_keyboard_tool_installed(self): with only_these_tools(): - self.assertFalse(paste.ydotool_ready()) - with self.assertRaises(paste.PasteError): + self.assertFalse(paste.paste_ready()) + with self.assertRaises(paste.PasteError) as caught: paste.press() - - def test_ctrl_v_presses_down_then_lets_go_in_reverse(self): - self.assertEqual(self.run_press("ctrl+v"), - ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"]) - - def test_three_keys(self): - self.assertEqual(self.run_press("ctrl+shift+v"), - ["ydotool", "key", "29:1", "42:1", "47:1", - "47:0", "42:0", "29:0"]) + self.assertIn(self.here.keyboard, str(caught.exception)) def test_case_and_spacing_do_not_matter(self): - self.assertEqual(self.run_press(" Ctrl + V "), self.run_press("ctrl+v")) + self.assertEqual(self.press(" Ctrl + V "), self.press("ctrl+v")) - def test_the_synonyms_land_on_the_same_codes(self): - self.assertEqual(self.run_press("control+insert"), - ["ydotool", "key", "29:1", "110:1", "110:0", "29:0"]) - self.assertEqual(self.run_press("super+enter"), self.run_press("meta+return")) - - def test_a_key_nobody_mapped(self): - with only_these_tools("ydotool"), mock.patch.object(subprocess, "run"), \ + def test_a_key_nobody_mapped_is_refused_before_the_tool_runs(self): + """Whichever desktop it is, the shortcut is held to one table.""" + with only_these_tools(self.here.keyboard), \ + mock.patch.object(subprocess, "run") as run, \ self.assertRaises(paste.PasteError) as caught: paste.press("ctrl+f13") self.assertIn("f13", str(caught.exception)) - - def test_ydotoold_not_running_says_so(self): - with self.assertRaises(paste.PasteError) as caught: - self.run_press("ctrl+v", FakeCompleted(returncode=1, stderr="no socket")) - self.assertIn("ydotoold", str(caught.exception)) + run.assert_not_called() def test_a_tool_that_will_not_run(self): - with only_these_tools("ydotool"), \ + with only_these_tools(self.here.keyboard), \ + mock.patch.object(paste.time, "sleep", lambda seconds: None), \ mock.patch.object(subprocess, "run", side_effect=OSError("nope")), \ self.assertRaises(paste.PasteError): paste.press("ctrl+v") + def test_a_failed_key_press_names_the_tool_and_what_it_said(self): + with self.assertRaises(paste.PasteError) as caught: + self.press("ctrl+v", FakeCompleted(returncode=1, stderr="no socket")) + self.assertIn(self.here.keyboard, str(caught.exception)) + self.assertIn("no socket", str(caught.exception)) + + +@linux_only +class Wayland(DesktopContract, DikteTest): + env: ClassVar[dict] = {"XDG_SESSION_TYPE": "wayland", + "WAYLAND_DISPLAY": "wayland-0"} + here = paste.WAYLAND + + def test_ydotool_presses_down_then_lets_go_in_reverse(self): + self.assertEqual(self.press("ctrl+v"), + ["ydotool", "key", "29:1", "47:1", "47:0", "29:0"]) + + def test_three_keys(self): + self.assertEqual(self.press("ctrl+shift+v"), + ["ydotool", "key", "29:1", "42:1", "47:1", + "47:0", "42:0", "29:0"]) + + def test_the_synonyms_land_on_the_same_codes(self): + self.assertEqual(self.press("control+insert"), + ["ydotool", "key", "29:1", "110:1", "110:0", "29:0"]) + self.assertEqual(self.press("super+enter"), self.press("meta+return")) + + def test_a_failure_asks_after_the_daemon(self): + """ydotool needs ydotoold, and says nothing useful when it is not up.""" + with self.assertRaises(paste.PasteError) as caught: + self.press("ctrl+v", FakeCompleted(returncode=1, stderr="no socket")) + self.assertIn("ydotoold", str(caught.exception)) + + +@linux_only +class X11(DesktopContract, DikteTest): + env: ClassVar[dict] = {"XDG_SESSION_TYPE": "x11", "DISPLAY": ":0"} + here = paste.X11 + + def test_xdotool_takes_the_combination_as_one_argument(self): + self.assertEqual(self.press("ctrl+v"), + ["xdotool", "key", "--clearmodifiers", "ctrl+v"]) + + def test_three_keys(self): + self.assertEqual(self.press("ctrl+shift+v"), + ["xdotool", "key", "--clearmodifiers", "ctrl+shift+v"]) + + def test_the_keys_x_spells_differently(self): + """xdotool wants keysyms, not the names the code table is keyed by.""" + self.assertEqual(self.press("control+insert")[-1], "ctrl+Insert") + self.assertEqual(self.press("meta+enter")[-1], "super+Return") + + def test_no_daemon_to_ask_after(self): + with self.assertRaises(paste.PasteError) as caught: + self.press("ctrl+v", FakeCompleted(returncode=1, stderr="bad keysym")) + self.assertNotIn("ydotoold", str(caught.exception)) + if __name__ == "__main__": unittest.main()