diff --git a/normcap/clipboard/__init__.py b/normcap/clipboard/__init__.py index 76ab734a..796eae29 100644 --- a/normcap/clipboard/__init__.py +++ b/normcap/clipboard/__init__.py @@ -1,7 +1,13 @@ -from normcap.clipboard.main import ClipboardHandlers, copy, copy_with_handler +from normcap.clipboard.main import ( + Handler, + copy, + copy_with_handler, + get_available_handlers, +) __all__ = [ + "Handler", "copy", "copy_with_handler", - "ClipboardHandlers", + "get_available_handlers", ] diff --git a/normcap/clipboard/handlers/base.py b/normcap/clipboard/handlers/base.py deleted file mode 100644 index 942d4ae5..00000000 --- a/normcap/clipboard/handlers/base.py +++ /dev/null @@ -1,109 +0,0 @@ -import abc -import logging -import os -import re -import shutil -import subprocess -import sys -from typing import Optional - -logger = logging.getLogger(__name__) - - -class ClipboardHandlerBase(abc.ABC): - install_instructions: Optional[str] = None - - def copy(self, text: str) -> bool: - """Wraps the method actually copying the text to the system clipboard. - - Should not be overridden. Overriding _copy() should be enough. - """ - if not self.is_compatible(): - logger.warning( - "%s.copy() called on incompatible system!", - getattr(self, "__name__", ""), - ) - - try: - self._copy(text) - except Exception: - logger.exception("%s.copy() failed!", getattr(self, "__name__", "")) - return False - else: - return True - - @property - def name(self) -> str: - return self.__class__.__name__ - - @staticmethod - def _os_has_wayland_display_manager() -> bool: - if sys.platform != "linux": - return False - - xdg_session_type = os.environ.get("XDG_SESSION_TYPE", "").lower() - has_wayland_display_env = bool(os.environ.get("WAYLAND_DISPLAY", "")) - return "wayland" in xdg_session_type or has_wayland_display_env - - @staticmethod - def _os_has_awesome_wm() -> bool: - if sys.platform != "linux": - return False - - return "awesome" in os.environ.get("XDG_CURRENT_DESKTOP", "").lower() - - @staticmethod - def _get_gnome_version() -> str: - """Detect Gnome version of current session. - - Returns: - Version string or empty string if not detected. - """ - if sys.platform != "linux": - return "" - - if ( - not os.environ.get("GNOME_DESKTOP_SESSION_ID", "") - and "gnome" not in os.environ.get("XDG_CURRENT_DESKTOP", "").lower() - ): - return "" - - if not shutil.which("gnome-shell"): - return "" - - try: - output = subprocess.check_output( - ["gnome-shell", "--version"], # noqa: S607 - shell=False, # noqa: S603 - text=True, - ) - if result := re.search(r"\s+([\d.]+)", output.strip()): - gnome_version = result.groups()[0] - except Exception as e: - logger.warning("Exception when trying to get gnome version from cli %s", e) - return "" - else: - return gnome_version - - @staticmethod - @abc.abstractmethod - def _copy(text: str) -> None: - ... # pragma: no cover - - @abc.abstractmethod - def is_compatible(self) -> bool: - """Check if the system theoretically could to use this method. - - Returns: - System could be capable of using this method - """ - ... # pragma: no cover - - @abc.abstractmethod - def is_installed(self) -> bool: - """Check if the dependencies (binaries) for this method are available. - - Returns: - System has all necessary dependencies - """ - ... # pragma: no cover diff --git a/normcap/clipboard/handlers/pbcopy.py b/normcap/clipboard/handlers/pbcopy.py index becc115a..df7111ea 100644 --- a/normcap/clipboard/handlers/pbcopy.py +++ b/normcap/clipboard/handlers/pbcopy.py @@ -2,31 +2,31 @@ import subprocess import sys -from .base import ClipboardHandlerBase - logger = logging.getLogger(__name__) +install_instructions = "" # pbcopy is pre-installed on macOS + + +def copy(text: str) -> None: + subprocess.run( + ["pbcopy", "w"], # noqa: S607 + shell=False, # noqa: S603 + input=text.encode("utf-8"), + check=True, + timeout=30, + env={"LC_CTYPE": "UTF-8"}, + ) + + +def is_compatible() -> bool: + if sys.platform != "darwin": + logger.debug("%s is incompatible on non-macOS systems", __name__) + return False + + logger.debug("%s is compatible", __name__) + return True + -class PbCopyHandler(ClipboardHandlerBase): - @staticmethod - def _copy(text: str) -> None: - subprocess.run( - ["pbcopy", "w"], # noqa: S607 - shell=False, # noqa: S603 - input=text.encode("utf-8"), - check=True, - timeout=30, - env={"LC_CTYPE": "UTF-8"}, - ) - - def is_compatible(self) -> bool: - if sys.platform != "darwin": - logger.debug("%s is incompatible on non-macOS systems", self.name) - return False - - logger.debug("%s is compatible", self.name) - return True - - def is_installed(self) -> bool: - logger.debug("%s requires no dependencies", self.name) - return True +def is_installed() -> bool: + logger.debug("%s requires no dependencies", __name__) + return True diff --git a/normcap/clipboard/handlers/qtclipboard.py b/normcap/clipboard/handlers/qtclipboard.py index 38c8e22f..fea97f16 100644 --- a/normcap/clipboard/handlers/qtclipboard.py +++ b/normcap/clipboard/handlers/qtclipboard.py @@ -1,39 +1,44 @@ import logging from typing import Any, cast +from normcap.clipboard import system_info + try: from PySide6 import QtGui except ImportError: QtGui = cast(Any, None) -from .base import ClipboardHandlerBase logger = logging.getLogger(__name__) +install_instructions = ( + "Please install the Python package PySide6 with your preferred package manager." +) + + +def copy(text: str) -> None: + """Use QtWidgets.QApplication.clipboard to copy text to system clipboard.""" + app = QtGui.QGuiApplication.instance() or QtGui.QGuiApplication() + + cb = app.clipboard() # type: ignore # Type hint wrong in PySide6? + cb.setText(text) + app.processEvents() -class QtCopyHandler(ClipboardHandlerBase): - @staticmethod - def _copy(text: str) -> None: - """Use QtWidgets.QApplication.clipboard to copy text to system clipboard.""" - app = QtGui.QGuiApplication.instance() or QtGui.QGuiApplication() - cb = app.clipboard() # type: ignore # Type hint wrong in PySide6? - cb.setText(text) - app.processEvents() +def is_compatible() -> bool: + if not QtGui: + logger.debug("%s is not compatible on systems w/o PySide6", __name__) + return False - def is_compatible(self) -> bool: - if not QtGui: - logger.debug("%s is not compatible on systems w/o PySide6", self.name) - return False + if system_info.os_has_wayland_display_manager(): + logger.debug("%s is not compatible with Wayland", __name__) + return False - if self._os_has_wayland_display_manager(): - logger.debug("%s is not compatible with Wayland", self.name) - return False + logger.debug("%s is compatible", __name__) + return True - logger.debug("%s is compatible", self.name) - return True - def is_installed(self) -> bool: - logger.debug("%s requires no dependencies", self.name) - return True +def is_installed() -> bool: + logger.debug("%s requires no dependencies", __name__) + return True diff --git a/normcap/clipboard/handlers/windll.py b/normcap/clipboard/handlers/windll.py index 7b3cce5d..a2986413 100644 --- a/normcap/clipboard/handlers/windll.py +++ b/normcap/clipboard/handlers/windll.py @@ -17,10 +17,10 @@ from ctypes import c_size_t, c_wchar, c_wchar_p, get_errno, sizeof from typing import Any -from .base import ClipboardHandlerBase - logger = logging.getLogger(__name__) +install_instructions = "" # msvcrt is pre-installed on Windows + class CheckedCall: def __init__(self, f: Any) -> None: # noqa: ANN401 @@ -39,157 +39,158 @@ def __setattr__(self, key: str, value: Any) -> None: # noqa: ANN401 setattr(self.f, key, value) -class WindllHandler(ClipboardHandlerBase): - @staticmethod - def _copy(text: str) -> None: # noqa: PLR0915 - from ctypes.wintypes import ( - BOOL, - DWORD, - HANDLE, - HGLOBAL, - HINSTANCE, - HMENU, - HWND, - INT, - LPCSTR, - LPVOID, - UINT, +# TODO: Think about pulling ctype imports and context managers out +def copy(text: str) -> None: # noqa: PLR0915 + from ctypes.wintypes import ( + BOOL, + DWORD, + HANDLE, + HGLOBAL, + HINSTANCE, + HMENU, + HWND, + INT, + LPCSTR, + LPVOID, + UINT, + ) + + windll = ctypes.windll # type:ignore # not available on non-Windows systems + msvcrt = ctypes.CDLL("msvcrt") + + safeCreateWindowExA = CheckedCall(windll.user32.CreateWindowExA) # noqa: N806 + safeCreateWindowExA.argtypes = [ + DWORD, + LPCSTR, + LPCSTR, + DWORD, + INT, + INT, + INT, + INT, + HWND, + HMENU, + HINSTANCE, + LPVOID, + ] + safeCreateWindowExA.restype = HWND + + safeDestroyWindow = CheckedCall(windll.user32.DestroyWindow) # noqa: N806 + safeDestroyWindow.argtypes = [HWND] + safeDestroyWindow.restype = BOOL + + OpenClipboard = windll.user32.OpenClipboard # noqa: N806 + OpenClipboard.argtypes = [HWND] + OpenClipboard.restype = BOOL + + safeCloseClipboard = CheckedCall(windll.user32.CloseClipboard) # noqa: N806 + safeCloseClipboard.argtypes = [] + safeCloseClipboard.restype = BOOL + + safeEmptyClipboard = CheckedCall(windll.user32.EmptyClipboard) # noqa: N806 + safeEmptyClipboard.argtypes = [] + safeEmptyClipboard.restype = BOOL + + safeGetClipboardData = CheckedCall(windll.user32.GetClipboardData) # noqa: N806 + safeGetClipboardData.argtypes = [UINT] + safeGetClipboardData.restype = HANDLE + + safeSetClipboardData = CheckedCall(windll.user32.SetClipboardData) # noqa: N806 + safeSetClipboardData.argtypes = [UINT, HANDLE] + safeSetClipboardData.restype = HANDLE + + safeGlobalAlloc = CheckedCall(windll.kernel32.GlobalAlloc) # noqa: N806 + safeGlobalAlloc.argtypes = [UINT, c_size_t] + safeGlobalAlloc.restype = HGLOBAL + + safeGlobalLock = CheckedCall(windll.kernel32.GlobalLock) # noqa: N806 + safeGlobalLock.argtypes = [HGLOBAL] + safeGlobalLock.restype = LPVOID + + safeGlobalUnlock = CheckedCall(windll.kernel32.GlobalUnlock) # noqa: N806 + safeGlobalUnlock.argtypes = [HGLOBAL] + safeGlobalUnlock.restype = BOOL + + wcslen = CheckedCall(msvcrt.wcslen) + wcslen.argtypes = [c_wchar_p] + wcslen.restype = UINT + + GMEM_MOVEABLE = 0x0002 # noqa: N806 (lowercase) + CF_UNICODETEXT = 13 # noqa: N806 (lowercase) + + @contextlib.contextmanager + def window() -> Iterator[HWND]: + """Context that provides a valid Windows hwnd.""" + # we really just need the hwnd, so setting "STATIC" + # as predefined lpClass is just fine. + hwnd = safeCreateWindowExA( + 0, b"STATIC", None, 0, 0, 0, 0, 0, None, None, None, None ) - - windll = ctypes.windll # type:ignore # not available on non-Windows systems - msvcrt = ctypes.CDLL("msvcrt") - - safeCreateWindowExA = CheckedCall(windll.user32.CreateWindowExA) # noqa: N806 - safeCreateWindowExA.argtypes = [ - DWORD, - LPCSTR, - LPCSTR, - DWORD, - INT, - INT, - INT, - INT, - HWND, - HMENU, - HINSTANCE, - LPVOID, - ] - safeCreateWindowExA.restype = HWND - - safeDestroyWindow = CheckedCall(windll.user32.DestroyWindow) # noqa: N806 - safeDestroyWindow.argtypes = [HWND] - safeDestroyWindow.restype = BOOL - - OpenClipboard = windll.user32.OpenClipboard # noqa: N806 - OpenClipboard.argtypes = [HWND] - OpenClipboard.restype = BOOL - - safeCloseClipboard = CheckedCall(windll.user32.CloseClipboard) # noqa: N806 - safeCloseClipboard.argtypes = [] - safeCloseClipboard.restype = BOOL - - safeEmptyClipboard = CheckedCall(windll.user32.EmptyClipboard) # noqa: N806 - safeEmptyClipboard.argtypes = [] - safeEmptyClipboard.restype = BOOL - - safeGetClipboardData = CheckedCall(windll.user32.GetClipboardData) # noqa: N806 - safeGetClipboardData.argtypes = [UINT] - safeGetClipboardData.restype = HANDLE - - safeSetClipboardData = CheckedCall(windll.user32.SetClipboardData) # noqa: N806 - safeSetClipboardData.argtypes = [UINT, HANDLE] - safeSetClipboardData.restype = HANDLE - - safeGlobalAlloc = CheckedCall(windll.kernel32.GlobalAlloc) # noqa: N806 - safeGlobalAlloc.argtypes = [UINT, c_size_t] - safeGlobalAlloc.restype = HGLOBAL - - safeGlobalLock = CheckedCall(windll.kernel32.GlobalLock) # noqa: N806 - safeGlobalLock.argtypes = [HGLOBAL] - safeGlobalLock.restype = LPVOID - - safeGlobalUnlock = CheckedCall(windll.kernel32.GlobalUnlock) # noqa: N806 - safeGlobalUnlock.argtypes = [HGLOBAL] - safeGlobalUnlock.restype = BOOL - - wcslen = CheckedCall(msvcrt.wcslen) - wcslen.argtypes = [c_wchar_p] - wcslen.restype = UINT - - GMEM_MOVEABLE = 0x0002 # noqa: N806 (lowercase) - CF_UNICODETEXT = 13 # noqa: N806 (lowercase) - - @contextlib.contextmanager - def window() -> Iterator[HWND]: - """Context that provides a valid Windows hwnd.""" - # we really just need the hwnd, so setting "STATIC" - # as predefined lpClass is just fine. - hwnd = safeCreateWindowExA( - 0, b"STATIC", None, 0, 0, 0, 0, 0, None, None, None, None - ) - try: - yield hwnd - finally: - safeDestroyWindow(hwnd) - - @contextlib.contextmanager - def clipboard(hwnd: HWND) -> Generator: - """Open the clipboard and prevents other apps from modifying its content.""" - # We may not get the clipboard handle immediately because - # some other application is accessing it (?) - # We try for at least 500ms to get the clipboard. - t = time.time() + 0.5 - success = False - while time.time() < t: - success = OpenClipboard(hwnd) - if success: - break - time.sleep(0.01) - if not success: - raise RuntimeError("Error calling OpenClipboard") - - try: - yield - finally: - safeCloseClipboard() - - def copy_windows(text: str) -> None: - with window() as hwnd, clipboard(hwnd): - # http://msdn.com/ms649048 - # If an application calls OpenClipboard with hwnd set to NULL, - # EmptyClipboard sets the clipboard owner to NULL; - # this causes SetClipboardData to fail. - # => We need a valid hwnd to copy something. - safeEmptyClipboard() - - if text: - # http://msdn.com/ms649051 - # If the hMem parameter identifies a memory object, - # the object must have been allocated using the - # function with the GMEM_MOVEABLE flag. - count = wcslen(text) + 1 - handle = safeGlobalAlloc(GMEM_MOVEABLE, count * sizeof(c_wchar)) - locked_handle = safeGlobalLock(handle) - - ctypes.memmove( - c_wchar_p(locked_handle), - c_wchar_p(text), - count * sizeof(c_wchar), - ) - - safeGlobalUnlock(handle) - safeSetClipboardData(CF_UNICODETEXT, handle) - - copy_windows(text) - - def is_compatible(self) -> bool: - if sys.platform != "win32": - logger.debug("%s is incompatible on non-Windows systems", self.name) - return False - - logger.debug("%s is compatible", self.name) - return True - - def is_installed(self) -> bool: - logger.debug("%s requires no dependencies", self.name) - return True + try: + yield hwnd + finally: + safeDestroyWindow(hwnd) + + @contextlib.contextmanager + def clipboard(hwnd: HWND) -> Generator: + """Open the clipboard and prevents other apps from modifying its content.""" + # We may not get the clipboard handle immediately because + # some other application is accessing it (?) + # We try for at least 500ms to get the clipboard. + t = time.time() + 0.5 + success = False + while time.time() < t: + success = OpenClipboard(hwnd) + if success: + break + time.sleep(0.01) + if not success: + raise RuntimeError("Error calling OpenClipboard") + + try: + yield + finally: + safeCloseClipboard() + + def copy_windows(text: str) -> None: + with window() as hwnd, clipboard(hwnd): + # http://msdn.com/ms649048 + # If an application calls OpenClipboard with hwnd set to NULL, + # EmptyClipboard sets the clipboard owner to NULL; + # this causes SetClipboardData to fail. + # => We need a valid hwnd to copy something. + safeEmptyClipboard() + + if text: + # http://msdn.com/ms649051 + # If the hMem parameter identifies a memory object, + # the object must have been allocated using the + # function with the GMEM_MOVEABLE flag. + count = wcslen(text) + 1 + handle = safeGlobalAlloc(GMEM_MOVEABLE, count * sizeof(c_wchar)) + locked_handle = safeGlobalLock(handle) + + ctypes.memmove( + c_wchar_p(locked_handle), + c_wchar_p(text), + count * sizeof(c_wchar), + ) + + safeGlobalUnlock(handle) + safeSetClipboardData(CF_UNICODETEXT, handle) + + copy_windows(text) + + +def is_compatible() -> bool: + if sys.platform != "win32": + logger.debug("%s is incompatible on non-Windows systems", __name__) + return False + + logger.debug("%s is compatible", __name__) + return True + + +def is_installed() -> bool: + logger.debug("%s requires no dependencies", __name__) + return True diff --git a/normcap/clipboard/handlers/wlclipboard.py b/normcap/clipboard/handlers/wlclipboard.py index 341e8abc..b0b33075 100644 --- a/normcap/clipboard/handlers/wlclipboard.py +++ b/normcap/clipboard/handlers/wlclipboard.py @@ -3,56 +3,57 @@ import subprocess from pathlib import Path -from .base import ClipboardHandlerBase +from normcap.clipboard import system_info logger = logging.getLogger(__name__) -class WlCopyHandler(ClipboardHandlerBase): - install_instructions = ( - "Please install the package 'wl-clipboard' with your system's package manager." +install_instructions = ( + "Please install the package 'wl-clipboard' with your system's package manager." +) + + +def copy(text: str) -> None: + """Use wl-clipboard package to copy text to system clipboard.""" + subprocess.run( + args=["wl-copy"], + shell=False, + input=text, + encoding="utf-8", + check=True, + # It seems like wl-copy works more reliable when output is piped to + # somewhere. This is e.g. the case when NormCap got started via a shortcut + # on KDE (#422). + stdout=Path("/dev/null").open("w"), # noqa: SIM115 + timeout=5, ) - @staticmethod - def _copy(text: str) -> None: - """Use wl-clipboard package to copy text to system clipboard.""" - subprocess.run( - args=["wl-copy"], - shell=False, - input=text, - encoding="utf-8", - check=True, - # It seems like wl-copy works more reliable when output is piped to - # somewhere. This is e.g. the case when NormCap got started via a shortcut - # on KDE (#422). - stdout=Path("/dev/null").open("w"), # noqa: SIM115 - timeout=5, + +def is_compatible() -> bool: + if not system_info.os_has_wayland_display_manager(): + logger.debug( + "%s is not compatible on non-Linux systems and on Linux w/o Wayland", + __name__, ) + return False + + if system_info.os_has_awesome_wm(): + logger.debug("%s is not compatible with Awesome WM", __name__) + return False + + gnome_version = system_info.get_gnome_version() + if gnome_version.startswith("45."): + logger.debug("%s is not compatible with Gnome %s", __name__, gnome_version) + return False + + logger.debug("%s is compatible", __name__) + return True + + +def is_installed() -> bool: + if not (wl_copy_bin := shutil.which("wl-copy")): + logger.debug("%s is not installed: wl-copy was not found", __name__) + return False - def is_compatible(self) -> bool: - if not self._os_has_wayland_display_manager(): - logger.debug( - "%s is not compatible on non-Linux systems and on Linux w/o Wayland", - self.name, - ) - return False - - if self._os_has_awesome_wm(): - logger.debug("%s is not compatible with Awesome WM", self.name) - return False - - gnome_version = self._get_gnome_version() - if gnome_version.startswith("45."): - logger.debug("%s is not compatible with Gnome %s", self.name, gnome_version) - return False - - logger.debug("%s is compatible", self.name) - return True - - def is_installed(self) -> bool: - if not (wl_copy_bin := shutil.which("wl-copy")): - logger.debug("%s is not installed: wl-copy was not found", self.name) - return False - - logger.debug("%s dependencies are installed (%s)", self.name, wl_copy_bin) - return True + logger.debug("%s dependencies are installed (%s)", __name__, wl_copy_bin) + return True diff --git a/normcap/clipboard/handlers/xclip.py b/normcap/clipboard/handlers/xclip.py index cb68a7cf..6a0f6171 100644 --- a/normcap/clipboard/handlers/xclip.py +++ b/normcap/clipboard/handlers/xclip.py @@ -4,44 +4,43 @@ import sys from pathlib import Path -from .base import ClipboardHandlerBase - logger = logging.getLogger(__name__) -class XclipCopyHandler(ClipboardHandlerBase): - install_instructions = ( - "Please install the package 'xclip' " "with your system's package manager." +install_instructions = ( + "Please install the package 'xclip' " "with your system's package manager." +) + + +def copy(text: str) -> None: + """Use xclip package to copy text to system clipboard.""" + subprocess.run( + args=["xclip", "-selection", "clipboard", "-in"], + shell=False, + input=text, + encoding="utf-8", + check=True, + # It seems like wl-copy works more reliable when output is piped to + # somewhere. This is e.g. the case when NormCap got started via a shortcut + # on KDE (#422). + stdout=Path("/dev/null").open("w"), # noqa: SIM115 + timeout=30, ) - @staticmethod - def _copy(text: str) -> None: - """Use xclip package to copy text to system clipboard.""" - subprocess.run( - args=["xclip", "-selection", "clipboard", "-in"], - shell=False, - input=text, - encoding="utf-8", - check=True, - # It seems like wl-copy works more reliable when output is piped to - # somewhere. This is e.g. the case when NormCap got started via a shortcut - # on KDE (#422). - stdout=Path("/dev/null").open("w"), # noqa: SIM115 - timeout=30, - ) - - def is_compatible(self) -> bool: - if sys.platform != "linux": - logger.debug("%s is not compatible on non-Linux systems", self.name) - return False - - logger.debug("%s is compatible", self.name) - return True - - def is_installed(self) -> bool: - if not (xclip_bin := shutil.which("xclip")): - logger.debug("%s is not installed: xclip was not found", self.name) - return False - - logger.debug("%s dependencies are installed (%s)", self.name, xclip_bin) - return True + +def is_compatible() -> bool: + if sys.platform != "linux": + logger.debug("%s is not compatible on non-Linux systems", __name__) + return False + + logger.debug("%s is compatible", __name__) + return True + + +def is_installed() -> bool: + if not (xclip_bin := shutil.which("xclip")): + logger.debug("%s is not installed: xclip was not found", __name__) + return False + + logger.debug("%s dependencies are installed (%s)", __name__, xclip_bin) + return True diff --git a/normcap/clipboard/main.py b/normcap/clipboard/main.py index 299d90d5..b696dcbc 100644 --- a/normcap/clipboard/main.py +++ b/normcap/clipboard/main.py @@ -1,44 +1,40 @@ -import enum import logging +from normcap.clipboard.structures import Handler, HandlerProtocol + from .handlers import pbcopy, qtclipboard, windll, wlclipboard, xclip logger = logging.getLogger(__name__) -class ClipboardHandlers(enum.Enum): - """All supported clipboard handlers. - - The handlers are ordered by preference: copy() tries them from top to bottom - and uses the first one that is detected as compatible. - """ - - # For win32 - windll = windll.WindllHandler() - - # For darwin - pbcopy = pbcopy.PbCopyHandler() - - # Cross platform - # - Not working on linux with wayland - qt = qtclipboard.QtCopyHandler() - - # For linux with wayland - # - Not working with Awesome WM - # - Problems with Gnome 45: https://github.com/bugaevc/wl-clipboard/issues/168 - wlclipboard = wlclipboard.WlCopyHandler() - - # For linux with xorg and wayland - # - Seems not very robust on wayland - # - Works with Awesome WM - xclip = xclip.XclipCopyHandler() - - # TODO: Think about implementing a _real_ success check # by implementing "read from clipboard" for all handlers and check if the text can # be retrieved from clipboard after copy(). +_clipboard_handlers: dict[Handler, HandlerProtocol] = { + Handler.WINDLL: windll, + Handler.PBCOPY: pbcopy, + Handler.QT: qtclipboard, + Handler.WLCLIPBOARD: wlclipboard, + Handler.XCLIP: xclip, +} + + +def _copy(text: str, handler: Handler) -> bool: + clipboard_handler = _clipboard_handlers[handler] + if not clipboard_handler.is_compatible(): + logger.warning("%s's copy() called on incompatible system!", handler.name) + try: + clipboard_handler.copy(text=text) + except Exception: + logger.exception("%s's copy() failed!", handler.name) + return False + else: + logger.info("Text copied to clipboard using %s", handler.name) + return True + + def copy_with_handler(text: str, handler_name: str) -> bool: """Copy text to system clipboard using a specific handler. @@ -49,15 +45,7 @@ def copy_with_handler(text: str, handler_name: str) -> bool: Returns: If an error has occurred and the text likely was not copied correctly. """ - handler = ClipboardHandlers[handler_name].value - result = handler.copy(text) - - if result: - logger.debug("Text copied to clipboard using '%s.' handler", handler_name) - return True - - logger.warning("Clipboard handler '%s' did not succeed!", handler_name) - return False + return _copy(text=text, handler=Handler[handler_name.upper()]) def copy(text: str) -> bool: @@ -72,41 +60,48 @@ def copy(text: str) -> bool: actually copied, as it still depends on system capabilities. """ for handler in get_available_handlers(): - if copy_with_handler(text=text, handler_name=handler): + if _copy(text=text, handler=handler): return True logger.error("Unable to copy text to clipboard! (Increase log-level for details)") return False -def get_available_handlers() -> list[str]: - compatible_handlers = [h.name for h in ClipboardHandlers if h.value.is_compatible()] - logger.debug("Compatible clipboard handlers: %s", compatible_handlers) +def get_available_handlers() -> list[Handler]: + compatible_handlers = [h for h in Handler if _clipboard_handlers[h].is_compatible()] + logger.debug( + "Compatible clipboard handlers: %s", [h.name for h in compatible_handlers] + ) available_handlers = [ - n for n in compatible_handlers if ClipboardHandlers[n].value.is_installed() + n for n in compatible_handlers if _clipboard_handlers[n].is_installed() ] - logger.debug("Available clipboard handlers: %s", available_handlers) + logger.debug( + "Available clipboard handlers: %s", [h.name for h in available_handlers] + ) if not compatible_handlers: logger.error( "None of the implemented clipboard handlers is compatible with this system!" ) - elif not available_handlers: + return [] + + if not available_handlers: logger.error( "No working clipboard handler found for your system. " - "The preferred clipboard handler on your system would be '%s' but can't be " + "The preferred handler on your system would be %s but can't be " "used due to missing dependencies. %s", - compatible_handlers[0], - ClipboardHandlers[compatible_handlers[0]].value.install_instructions, + compatible_handlers[0].name, + _clipboard_handlers[compatible_handlers[0]].install_instructions, ) - elif compatible_handlers[0] != available_handlers[0]: + return [] + + if compatible_handlers[0] != available_handlers[0]: logger.warning( - "The preferred clipboard handler on your system would be '%s' but can't be " - "used due to missing dependencies. " - "If you experience any problems with the clipboard handling: %s", - compatible_handlers[0], - ClipboardHandlers[compatible_handlers[0]].value.install_instructions, + "The preferred clipboard handler on your system would be %s but can't be " + "used due to missing dependencies. %s", + compatible_handlers[0].name, + _clipboard_handlers[compatible_handlers[0]].install_instructions, ) return available_handlers diff --git a/normcap/clipboard/structures.py b/normcap/clipboard/structures.py new file mode 100644 index 00000000..99207f9d --- /dev/null +++ b/normcap/clipboard/structures.py @@ -0,0 +1,61 @@ +import enum +from typing import Protocol + + +class HandlerProtocol(Protocol): + install_instructions: str + + def copy(self, text: str) -> None: + """Copy text to system clipboard. + + Args: + text: Text to be copied. + + Returns: + False, if an error has occurred. + """ + ... # pragma: no cover + + def is_compatible(self) -> bool: + """Check if the system theoretically could to use this method. + + Returns: + System could be capable of using this method + """ + ... # pragma: no cover + + def is_installed(self) -> bool: + """Check if the dependencies (binaries) for this method are available. + + Returns: + System has all necessary dependencies + """ + ... # pragma: no cover + + +class Handler(enum.IntEnum): + """All supported clipboard handlers. + + The handlers are ordered by preference: copy() tries them from top to bottom + and uses the first one that is detected as compatible. + """ + + # For win32 + WINDLL = enum.auto() + + # For darwin + PBCOPY = enum.auto() + + # Cross platform + # - Not working on linux with wayland + QT = enum.auto() + + # For linux with wayland + # - Not working with Awesome WM + # - Problems with Gnome 45: https://github.com/bugaevc/wl-clipboard/issues/168 + WLCLIPBOARD = enum.auto() + + # For linux with xorg and wayland + # - Seems not very robust on wayland + # - Works with Awesome WM + XCLIP = enum.auto() diff --git a/normcap/clipboard/system_info.py b/normcap/clipboard/system_info.py new file mode 100644 index 00000000..24098538 --- /dev/null +++ b/normcap/clipboard/system_info.py @@ -0,0 +1,57 @@ +import logging +import os +import re +import shutil +import subprocess +import sys + +logger = logging.getLogger(__name__) + + +def os_has_wayland_display_manager() -> bool: + if sys.platform != "linux": + return False + + xdg_session_type = os.environ.get("XDG_SESSION_TYPE", "").lower() + has_wayland_display_env = bool(os.environ.get("WAYLAND_DISPLAY", "")) + return "wayland" in xdg_session_type or has_wayland_display_env + + +def os_has_awesome_wm() -> bool: + if sys.platform != "linux": + return False + + return "awesome" in os.environ.get("XDG_CURRENT_DESKTOP", "").lower() + + +def get_gnome_version() -> str: + """Detect Gnome version of current session. + + Returns: + Version string or empty string if not detected. + """ + if sys.platform != "linux": + return "" + + if ( + not os.environ.get("GNOME_DESKTOP_SESSION_ID", "") + and "gnome" not in os.environ.get("XDG_CURRENT_DESKTOP", "").lower() + ): + return "" + + if not shutil.which("gnome-shell"): + return "" + + try: + output = subprocess.check_output( + ["gnome-shell", "--version"], # noqa: S607 + shell=False, # noqa: S603 + text=True, + ) + if result := re.search(r"\s+([\d\.]+)", output.strip()): + gnome_version = result.groups()[0] + except Exception as e: + logger.warning("Exception when trying to get gnome version from cli %s", e) + return "" + else: + return gnome_version diff --git a/normcap/gui/tray.py b/normcap/gui/tray.py index 5f50b726..b2a06458 100644 --- a/normcap/gui/tray.py +++ b/normcap/gui/tray.py @@ -325,7 +325,7 @@ def _copy_to_clipboard(self) -> None: logger.debug("Nothing there to be copied to clipboard!") elif self.clipboard_handler_name: logger.debug( - "Copy text to clipboard with '%s'", self.clipboard_handler_name + "Copy text to clipboard with %s", self.clipboard_handler_name.upper() ) clipboard.copy_with_handler( text=self.capture.ocr_text, handler_name=self.clipboard_handler_name diff --git a/normcap/resources/locales/README.md b/normcap/resources/locales/README.md index 0c421e02..b21b6a08 100644 --- a/normcap/resources/locales/README.md +++ b/normcap/resources/locales/README.md @@ -13,19 +13,19 @@ -| Locale | Progress | Translated | -| :----- | -------: | ---------: | -| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 100% | 68 of 68 | -| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 8% | 6 of 68 | -| [fr_FR](./fr_FR/LC_MESSAGES/messages.po) | 8% | 6 of 68 | -| [hi_IN](./hi_IN/LC_MESSAGES/messages.po) | 8% | 6 of 68 | -| [it_IT](./it_IT/LC_MESSAGES/messages.po) | 8% | 6 of 68 | -| [pl_PL](./pl_PL/LC_MESSAGES/messages.po) | 8% | 6 of 68 | -| [pt_PT](./pt_PT/LC_MESSAGES/messages.po) | 8% | 6 of 68 | -| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 8% | 6 of 68 | -| [sv_SE](./sv_SE/LC_MESSAGES/messages.po) | 100% | 68 of 68 | -| [uk_UA](./uk_UA/LC_MESSAGES/messages.po) | 8% | 6 of 68 | -| [zh_CN](./zh_CN/LC_MESSAGES/messages.po) | 8% | 6 of 68 | +| Locale | Progress | Translated | +| :--------------------------------------- | -------: | ---------: | +| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 100% | 68 of 68 | +| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 8% | 6 of 68 | +| [fr_FR](./fr_FR/LC_MESSAGES/messages.po) | 8% | 6 of 68 | +| [hi_IN](./hi_IN/LC_MESSAGES/messages.po) | 8% | 6 of 68 | +| [it_IT](./it_IT/LC_MESSAGES/messages.po) | 8% | 6 of 68 | +| [pl_PL](./pl_PL/LC_MESSAGES/messages.po) | 8% | 6 of 68 | +| [pt_PT](./pt_PT/LC_MESSAGES/messages.po) | 8% | 6 of 68 | +| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 8% | 6 of 68 | +| [sv_SE](./sv_SE/LC_MESSAGES/messages.po) | 100% | 68 of 68 | +| [uk_UA](./uk_UA/LC_MESSAGES/messages.po) | 8% | 6 of 68 | +| [zh_CN](./zh_CN/LC_MESSAGES/messages.po) | 8% | 6 of 68 | ## Proofread existing translations diff --git a/normcap/screengrab/utils.py b/normcap/screengrab/utils.py index c616ce70..5455a335 100644 --- a/normcap/screengrab/utils.py +++ b/normcap/screengrab/utils.py @@ -72,7 +72,7 @@ def get_gnome_version() -> Optional[str]: shell=False, # noqa: S603 text=True, ) - if result := re.search(r"\s+([\d.]+)", output.strip()): + if result := re.search(r"\s+([\d\.]+)", output.strip()): gnome_version = result.groups()[0] except Exception as e: logger.warning("Exception when trying to get gnome version from cli %s", e) diff --git a/normcap/utils.py b/normcap/utils.py index da27e54b..cebdf3b0 100644 --- a/normcap/utils.py +++ b/normcap/utils.py @@ -10,7 +10,7 @@ from PySide6 import QtCore -from normcap.clipboard import ClipboardHandlers +from normcap.clipboard import Handler from normcap.gui import system_info from normcap.gui.settings import DEFAULT_SETTINGS @@ -83,7 +83,7 @@ def create_argparser() -> argparse.ArgumentParser: parser.add_argument( "--clipboard-handler", action="store", - choices=[h.name for h in ClipboardHandlers], + choices=[h.name.lower() for h in Handler], help="Force using specific clipboard handler instead of auto-selecting", ) return parser diff --git a/tests/tests_clipboard/test_main.py b/tests/tests_clipboard/test_main.py index fca3a9b0..8b997557 100644 --- a/tests/tests_clipboard/test_main.py +++ b/tests/tests_clipboard/test_main.py @@ -1,3 +1,7 @@ +import shutil + +import pytest + from normcap import clipboard @@ -12,15 +16,25 @@ def test_copy(): def test_copy_without_compatible_handler_fails(monkeypatch): # GIVEN a system is mocked to not supported any implemented clipboard handler - for handler in clipboard.ClipboardHandlers: - monkeypatch.setattr( - clipboard.ClipboardHandlers[handler.name].value, - "is_compatible", - lambda: False, - ) + for handler in clipboard.main._clipboard_handlers.values(): + monkeypatch.setattr(handler, "is_compatible", lambda: False) # WHEN text is copied to the clipboard result = clipboard.copy(text="this is a test") # THEN we expect an unsuccessful return value assert result is False + + +@pytest.mark.skipif( + clipboard.system_info.os_has_wayland_display_manager() + and not (shutil.which("wl-copy") or shutil.which("xclip")), + reason="Needs wl-cop or xclip", +) +def test_get_available_handlers(): + # GIVEN a system with at least one compatible and installed clipboard handler + # WHEN we call get_available_handlers + available_handlers = clipboard.main.get_available_handlers() + + # THEN we expect at least one handler to be available + assert available_handlers diff --git a/tests/tests_clipboard/test_pbcopy.py b/tests/tests_clipboard/test_pbcopy.py index 5e8991e7..6f408a2f 100644 --- a/tests/tests_clipboard/test_pbcopy.py +++ b/tests/tests_clipboard/test_pbcopy.py @@ -17,13 +17,13 @@ ) def test_pbcopy_is_compatible(monkeypatch, platform, result): monkeypatch.setattr(pbcopy.sys, "platform", platform) - assert pbcopy.PbCopyHandler().is_compatible() == result + assert pbcopy.is_compatible() == result @pytest.mark.skipif(sys.platform != "darwin", reason="macOS specific test") def test_pbcopy_copy(): text = f"this is a unique test {uuid.uuid4()}" - result = pbcopy.PbCopyHandler().copy(text=text) + pbcopy.copy(text=text) with subprocess.Popen( ["pbpaste", "r"], # noqa: S603, S607 @@ -32,11 +32,10 @@ def test_pbcopy_copy(): stdout = p.communicate()[0] clipped = stdout.decode("utf-8") - assert result is True assert text == clipped @pytest.mark.skipif(sys.platform == "darwin", reason="Non-macOS specific test") def test_pbcopy_copy_on_non_darwin(): - result = pbcopy.PbCopyHandler().copy(text="this is a test") - assert result is False + with pytest.raises((FileNotFoundError, OSError)): + pbcopy.copy(text="this is a test") diff --git a/tests/tests_clipboard/test_qtclipboard.py b/tests/tests_clipboard/test_qtclipboard.py index 050baa35..4c9e8ba9 100644 --- a/tests/tests_clipboard/test_qtclipboard.py +++ b/tests/tests_clipboard/test_qtclipboard.py @@ -3,8 +3,8 @@ import pytest -from normcap.clipboard.handlers import base, qtclipboard -from normcap.clipboard.handlers.base import ClipboardHandlerBase +from normcap.clipboard import system_info +from normcap.clipboard.handlers import qtclipboard real_import = builtins.__import__ @@ -23,51 +23,35 @@ def test_qtclipboard_is_compatible(monkeypatch, platform, wayland_display, result): monkeypatch.setenv("WAYLAND_DISPLAY", wayland_display) monkeypatch.setenv("XDG_SESSION_TYPE", "") - monkeypatch.setattr(base.sys, "platform", platform) - assert qtclipboard.QtCopyHandler().is_compatible() == result + monkeypatch.setattr(system_info.sys, "platform", platform) + assert qtclipboard.is_compatible() == result def test_qtclipboard_is_compatible_without_pyside6(monkeypatch, mock_import): mock_import(parent_module=qtclipboard, import_name="QtGui", throw_exc=ImportError) monkeypatch.setenv("WAYLAND_DISPLAY", "") monkeypatch.setenv("XDG_SESSION_TYPE", "") - assert qtclipboard.QtCopyHandler().is_compatible() is False + assert qtclipboard.is_compatible() is False @pytest.mark.skipif( - ClipboardHandlerBase._os_has_wayland_display_manager(), - reason="non-Wayland specific test", + system_info.os_has_wayland_display_manager(), reason="non-Wayland specific test" ) def test_qtclipboard_copy(qapp): text = "test" - result = qtclipboard.QtCopyHandler().copy(text=text) + qtclipboard.copy(text=text) clipped = qapp.clipboard().text() - assert result is True assert text == clipped -@pytest.mark.skipif( - not ClipboardHandlerBase._os_has_wayland_display_manager(), - reason="Wayland specific test", -) -def test_qtclipboard_copy_on_wayland_fails(qapp): - text = f"this is a unique test {uuid.uuid4()}" - - result = qtclipboard.QtCopyHandler().copy(text=text) - clipped = qapp.clipboard().text() - - assert result # Command succeeded - assert clipped is not text # But text is not copied - - def test_qtclipboard_copy_fails_on_missing_pyside6(qapp, mock_import): text = f"this is a unique test {uuid.uuid4()}" mock_import(parent_module=qtclipboard, import_name="QtGui", throw_exc=ImportError) - result = qtclipboard.QtCopyHandler().copy(text=text) + with pytest.raises((ImportError, AttributeError)): + qtclipboard.copy(text=text) clipped = qapp.clipboard().text() - assert result is False assert text != clipped diff --git a/tests/tests_clipboard/test_system_info.py b/tests/tests_clipboard/test_system_info.py new file mode 100644 index 00000000..4c65e0bc --- /dev/null +++ b/tests/tests_clipboard/test_system_info.py @@ -0,0 +1,68 @@ +import pytest + +from normcap.clipboard import system_info + + +@pytest.mark.parametrize( + ("xdg_session_type", "wayland_display", "platform", "expected_result"), + [ + ("wayland", "wayland", "linux", True), + ("wayland", "", "linux", True), + ("", "wayland", "linux", True), + ("", "", "linux", False), + ("wayland", "wayland", "win32", False), + ("wayland", "wayland", "darwin", False), + ], +) +def test_os_has_wayland_display_manager( + monkeypatch, xdg_session_type, wayland_display, platform, expected_result +): + monkeypatch.setenv("XDG_SESSION_TYPE", xdg_session_type) + monkeypatch.setenv("WAYLAND_DISPLAY", wayland_display) + monkeypatch.setattr(system_info.sys, "platform", platform) + + assert system_info.os_has_wayland_display_manager() == expected_result + + +@pytest.mark.parametrize( + ("platform", "desktop", "expected_result"), + [ + ("linux", "awesome", True), + ("linux", "gnome", False), + ("win32", "awesome", False), + ("darwin", "awesome", False), + ], +) +def test_os_has_awesome_wm(monkeypatch, platform, desktop, expected_result): + with monkeypatch.context() as m: + m.setenv("XDG_CURRENT_DESKTOP", desktop) + m.setattr(system_info.sys, "platform", platform) + assert system_info.os_has_awesome_wm() == expected_result + + +@pytest.mark.parametrize( + ("platform", "xdg_desktop", "gnome_desktop", "gnome_shell", "expected_result"), + [ + ("linux", "gnome", "gnome", "/usr/bin/gnome-shell", "33.3.0"), + ("linux", "kde", "", "/usr/bin/gnome-shell", ""), + ("darwin", "", "", "", ""), + ("darwin", "", "", "/usr/bin/gnome-shell", ""), + ("win32", "", "", "", ""), + ], +) +def test_get_gnome_version( + platform, xdg_desktop, gnome_desktop, gnome_shell, expected_result, monkeypatch +): + monkeypatch.setenv("XDG_CURRENT_DESKTOP", xdg_desktop) + monkeypatch.setenv("GNOME_DESKTOP_SESSION_ID", gnome_desktop) + monkeypatch.setattr(system_info.sys, "platform", platform) + monkeypatch.setattr( + system_info.shutil, + "which", + lambda x: gnome_shell if x == "gnome-shell" else None, + ) + monkeypatch.setattr( + system_info.subprocess, "check_output", lambda *_, **__: "GNOME Shell 33.3.0" + ) + + assert system_info.get_gnome_version() == expected_result diff --git a/tests/tests_clipboard/test_windll.py b/tests/tests_clipboard/test_windll.py index 564bb0c1..7dea6c07 100644 --- a/tests/tests_clipboard/test_windll.py +++ b/tests/tests_clipboard/test_windll.py @@ -33,13 +33,13 @@ def clipboard_blocked(): ) def test_windll_is_compatible(monkeypatch, platform, result): monkeypatch.setattr(windll.sys, "platform", platform) - assert windll.WindllHandler().is_compatible() == result + assert windll.is_compatible() == result @pytest.mark.skipif(sys.platform != "win32", reason="Windows specific test") def test_windll_copy(): text = "test" - result = windll.WindllHandler().copy(text=text) + windll.copy(text=text) with subprocess.Popen( ["powershell", "-command", "Get-Clipboard"], # noqa: S603, S607 @@ -48,24 +48,16 @@ def test_windll_copy(): stdout = p.communicate()[0] clipped = stdout.decode("utf-8").strip() - assert result is True assert text == clipped @pytest.mark.skipif(sys.platform == "win32", reason="Non-Windows specific test") def test_windll_copy_on_non_win32(): - result = windll.WindllHandler().copy(text="this is a test") - assert result is False - - -@pytest.mark.skipif(sys.platform == "win32", reason="non-Windows specific test") -def test_windll_copy_on_unsupported_platform_fails(): - result = windll.WindllHandler().copy(text="test") - assert result is False + with pytest.raises(AttributeError): + windll.copy(text="this is a test") @pytest.mark.skipif(sys.platform != "win32", reason="Windows specific test") def test_windll_copy_with_blocked_clipboard_fails(): - with clipboard_blocked(): - result = windll.WindllHandler().copy(text="test") - assert result is False + with clipboard_blocked(), pytest.raises(RuntimeError): + windll.copy(text="test") diff --git a/tests/tests_clipboard/test_wlclipboard.py b/tests/tests_clipboard/test_wlclipboard.py index 3828b742..3810549f 100644 --- a/tests/tests_clipboard/test_wlclipboard.py +++ b/tests/tests_clipboard/test_wlclipboard.py @@ -5,7 +5,8 @@ import pytest -from normcap.clipboard.handlers import base, wlclipboard +from normcap.clipboard import system_info +from normcap.clipboard.handlers import wlclipboard @pytest.mark.parametrize( @@ -26,12 +27,10 @@ def test_wlcopy_is_compatible( ): monkeypatch.setenv("WAYLAND_DISPLAY", wayland_display) monkeypatch.setenv("XDG_SESSION_TYPE", xdg_session_type) - monkeypatch.setattr(base.sys, "platform", platform) - monkeypatch.setattr( - base.ClipboardHandlerBase, "_get_gnome_version", lambda *_: gnome_version - ) + monkeypatch.setattr(system_info.sys, "platform", platform) + monkeypatch.setattr(system_info, "get_gnome_version", lambda *_: gnome_version) - assert wlclipboard.WlCopyHandler().is_compatible() == result + assert wlclipboard.is_compatible() == result @pytest.mark.parametrize( @@ -42,12 +41,12 @@ def test_wlcopy_is_compatible( ], ) def test_wlcopy_is_installed(platform, has_wlcopy, result, monkeypatch): - monkeypatch.setattr(base.sys, "platform", platform) + monkeypatch.setattr(system_info.sys, "platform", platform) monkeypatch.setattr( wlclipboard.shutil, "which", lambda *args: "wl-copy" in args and has_wlcopy ) - assert wlclipboard.WlCopyHandler().is_installed() == result + assert wlclipboard.is_installed() == result # ONHOLD: Check if wl-copy works on Gnome 45 without the need to click on notification. @@ -58,7 +57,7 @@ def test_wlcopy_is_installed(platform, has_wlcopy, result, monkeypatch): def test_wlcopy_copy(): text = f"this is a unique test {uuid.uuid4()}" - result = wlclipboard.WlCopyHandler().copy(text=text) + wlclipboard.copy(text=text) with subprocess.Popen( ["wl-paste"], # noqa: S603, S607 @@ -67,12 +66,11 @@ def test_wlcopy_copy(): stdout = p.communicate()[0] clipped = stdout.decode("utf-8").strip() - assert result is True assert text == clipped @pytest.mark.skipif(True, reason="Buggy in Gnome 45") @pytest.mark.skipif(sys.platform == "linux", reason="Non-Linux specific test") def test_wlcopy_copy_on_non_linux(): - result = wlclipboard.WlCopyHandler().copy(text="this is a test") - assert result is False + with pytest.raises((FileNotFoundError, OSError)): + wlclipboard.copy(text="this is a test") diff --git a/tests/tests_clipboard/test_xclip.py b/tests/tests_clipboard/test_xclip.py index d902999a..f8582e45 100644 --- a/tests/tests_clipboard/test_xclip.py +++ b/tests/tests_clipboard/test_xclip.py @@ -5,7 +5,8 @@ import pytest -from normcap.clipboard.handlers import base, xclip +from normcap.clipboard import system_info +from normcap.clipboard.handlers import xclip @pytest.mark.parametrize( @@ -28,12 +29,12 @@ def test_xclip_is_compatible( monkeypatch.setenv("WAYLAND_DISPLAY", wayland_display) monkeypatch.setenv("XDG_SESSION_TYPE", xdg_session_type) - monkeypatch.setattr(base.sys, "platform", platform) + monkeypatch.setattr(system_info.sys, "platform", platform) monkeypatch.setattr( xclip.shutil, "which", lambda *args: "xclip" in args and has_xclip ) - assert xclip.XclipCopyHandler().is_compatible() == result + assert xclip.is_compatible() == result @pytest.mark.skipif(not shutil.which("xclip"), reason="Needs xclip") @@ -41,7 +42,7 @@ def test_xclip_is_compatible( def test_xclip_copy(): text = f"this is a unique test {uuid.uuid4()}" - result = xclip.XclipCopyHandler().copy(text=text) + xclip.copy(text=text) with subprocess.Popen( ["xclip", "-selection", "clipboard", "-out"], # noqa: S603, S607 @@ -50,11 +51,10 @@ def test_xclip_copy(): stdout = p.communicate()[0] clipped = stdout.decode("utf-8").strip() - assert result is True assert text == clipped @pytest.mark.skipif(sys.platform == "linux", reason="Non-Linux specific test") def test_xclip_copy_on_non_linux(): - result = xclip.XclipCopyHandler().copy(text="this is a test") - assert result is False + with pytest.raises((FileNotFoundError, OSError)): + xclip.copy(text="this is a test")