📋 Python3 module for working with clipboard. Created because pyperclip is discontinued.  Mostly made for ✨ YTCON
# -*- coding: utf-8 -*-
"""
 * Copyright (C) 2023 Nikita Beloglazov <nnikita.beloglazov@gmail.com>
 *
 * This file is part of github.com/NikitaBeloglazov/clipman.
 *
 * NikitaBeloglazov/clipman is free software; you can redistribute it and/or
 * modify it under the terms of the Mozilla Public License 2.0
 * published by the Mozilla Foundation.
 *
 * NikitaBeloglazov/clipman is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY.
 *
 * You should have received a copy of the Mozilla Public License 2.0
 * along with NikitaBeloglazov/clipman
 * If not, see https://mozilla.org/en-US/MPL/2.0.
"""
import os
import sys
import time
import shutil
import platform
import subprocess

from . import exceptions

def check_binary_installed(binary_name):
	""" Checks if binary is avalible in this OS """
	return shutil.which(binary_name) is not None

def detect_os():
	""" Detects name of OS """
	os_name = platform.system()

	if os_name == "Linux" and hasattr(sys, "getandroidapilevel"):
		# Detect Android by yourself because platform.system() detects Android as Linux
		return "Android"

	if os_name == "Darwin" and os.path.exists("/Users") and os.path.isdir("/Users"):
		# Detect macOS by specific directories in root (/)
		# by yourself because platform.system() detects macOS as Darwin, and BSD is Darwin too
		return "macOS"

	return os_name

def run_command(command, timeout=5):
	""" Binary file caller """
	try:
		runner = subprocess.run(command, timeout=timeout, shell=False, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
	except subprocess.TimeoutExpired as e:
		raise exceptions.EngineTimeoutExpired(f"The timeout for executing the command was exceeded: subprocess.TimeoutExpired: {e}")

	if runner.returncode != 0:
		raise exceptions.EngineError(f"Command returned non-zero exit status: {str(runner.returncode)}.\n- = -\nSTDERR: {runner.stderr.decode('UTF-8')}")

	return runner.stdout.decode("UTF-8").removesuffix("\n") # looks like all commands returns \n in the end

def run_command_with_paste(command, text):
	""" Calls binary, gives it the text to be copied, and exits"""
	with subprocess.Popen(command, stdin=subprocess.PIPE, close_fds=True) as runner:
		runner.communicate(input=text.encode("UTF-8"))

		if runner.returncode != 0:
			raise exceptions.EngineError(f"Command returned non-zero exit status: {str(runner.returncode)}.")

		runner.terminate()
		#time.sleep(0.2)
		#runner.kill()

def check_run_command(command, engine):
	"""
	command - command to check run
	engine - string that will be returned if check is succeful
	"""
	try:
		run_command(command)
	except exceptions.EngineTimeoutExpired as e:
		raise exceptions.EngineTimeoutExpired from e
	except exceptions.ClipmanBaseException as e:
		raise exceptions.EngineError(f"\"{command}\" gives unknown error. See output for above.") from e
	return engine

class DataClass():
	""" Class for storing module data """
	def __init__(self):
		self.windows_native_backend = None
		self.os_name = detect_os()
		self.engine = None
		self.init_called = False

dataclass = DataClass()

def detect_clipboard_engine():
	"""
	Detects clipboard engine based on many factors, and in many cases gives the user friendly advice.
	Returns name of detected engine
	"""
	if dataclass.os_name in ("Linux", "FreeBSD", "OpenBSD"):
		try:
			# Detect graphical backend from ENV
			graphical_backend = os.environ["XDG_SESSION_TYPE"]
		except KeyError:
			graphical_backend = "< NOT SET >"

		if graphical_backend == "x11":
			if check_binary_installed("xsel"): # Preffer xsel because is it less laggy and more fresh
				return check_run_command(['xsel', '-b', '-n', '-o'], "xsel")
			if check_binary_installed("xclip"):
				return check_run_command(['xclip', '-selection', 'c', '-o'], "xclip")
			raise exceptions.NoEnginesFoundError("Clipboard engines not found on your system. For Linux X11, you need to install \"xsel\" or \"xclip\" via your system package manager.")

		if graphical_backend == "wayland":
			if check_binary_installed("wl-paste"):
				return check_run_command(['wl-paste'], "wl-clipboard")
			raise exceptions.NoEnginesFoundError("Clipboard engines not found on your system. For Linux Wayland, you need to install \"wl-clipboard\" via your system package manager.")

		if graphical_backend == "tty":
			raise exceptions.UnsupportedError("Clipboard in TTY is unsupported.")

		# If graphical_backend is unknown
		raise exceptions.NoEnginesFoundError(f"The graphical backend (X11, Wayland) was not found on your Linux OS. Check XDG_SESSION_TYPE variable in your ENV. Also note that TTY is unsupported.\n\nXDG_SESSION_TYPE content: {graphical_backend}")
	# - = - = - = - = - = - = - = - = - = - = - = - = - = - =
	if dataclass.os_name == "Android":
		if check_binary_installed("termux-clipboard-get"):
			try:
				return check_run_command(['termux-clipboard-get'], "termux-clipboard")
			except exceptions.EngineTimeoutExpired as e:
				raise exceptions.NoEnginesFoundError("No usable clipboard engines found on your system. \"termux-clipboard-get\" finished with timeout, so that means Termux:API plug-in is not installed. Please install it from F-Droid and try again.") from e
		else:
			raise exceptions.NoEnginesFoundError("Clipboard engines not found on your system. For Android+Termux, you need to run \"pkg install termux-api\" and install \"Termux:API\" plug-in from F-Droid.")
	# - = - = - = - = - = - = - = - = - = - = - = - = - = - =
	if dataclass.os_name == "Windows":
		from . import windows # pylint: disable=C0415 # import-outside-toplevel
		dataclass.windows_native_backend = windows.WindowsClipboard()
		return "windows_native_backend"
	# - = - = - = - = - = - = - = - = - = - = - = - = - = - =
	if dataclass.os_name == "macOS":
		if check_binary_installed("pbpaste"):
			return check_run_command(['pbpaste'], "pboard")
		raise exceptions.NoEnginesFoundError("Clipboard engines not found on your system. For some reason, pbpaste (pboard) is not included in your macOS:((\nPlease make issue at https://github.com/NikitaBeloglazov/clipman/issues/new")
	# - = - = - = - = - = - = - = - = - = - = - = - = - = - =

	raise exceptions.UnsupportedError(f"Clipboard engines not found on your system. Seems like \"{dataclass.os_name}\" OS is unsupported. Please make issue at https://github.com/NikitaBeloglazov/clipman/issues/new")

def get():
	"""
	Gets & returns clipboard content as DECODED string.
	If there is a picture or copied file(in windows), it returns empty string
	"""
	return call("get")

def set(text): # pylint: disable=W0622 # redefined-builtin # i don't care
	"""
	Sets text to clipboard
	"""
	return call("set", text)

# Synonims
paste = get
copy = set

def call(method, text=None): # pylint: disable=R0911 # too-many-return-statements
	"""
	General method for calling engines. Very useful for maintenance

	# METHODS:
	# * set - (copy)  set to clipboard
	# * get - (paste) get text from clipboard
	"""
	if dataclass.init_called is False:
		raise exceptions.NoInitializationError
	if method == "set" and text is None:
		raise exceptions.TextNotSpecified("Not specified text to paste!")

	text = str(text)

	# - = LINUX - = - = - = - = - = - = - =
	if dataclass.engine == "xsel":
		if method == "set":
			return run_command_with_paste(['xsel', '-b', '-i'], text)
		if method == "get":
			return run_command(['xsel', '-b', '-n', '-o'])
	if dataclass.engine == "xclip":
		if method == "set":
			return run_command_with_paste(['xclip', '-selection', 'c', '-i'], text)
		if method == "get":
			return run_command(['xclip', '-selection', 'c', '-o'])
	if dataclass.engine == "wl-clipboard":
		if method == "set":
			try:
				return run_command(['wl-copy', text], timeout=3)
			except exceptions.EngineTimeoutExpired:
				return None # SEEMS like its okay, wl-copy for some reason remains in background
		if method == "get":
			return run_command(['wl-paste'])
	# - = - = - = - = - = - = - = - = - = -
	# - = Android = - = - = - = - = - = - =
	if dataclass.engine == "termux-clipboard":
		if method == "set":
			return run_command(['termux-clipboard-set', text])
		if method == "get":
			return run_command(['termux-clipboard-get'])
	# - = - = - = - = - = - = - = - = - = -
	# - = Windows = - = - = - = - = - = - =
	if dataclass.engine == "windows_native_backend":
		if method == "set":
			return dataclass.windows_native_backend.copy(text)
		if method == "get":
			return dataclass.windows_native_backend.paste()
	# - = - = - = - = - = - = - = - = - = -
	# - = macOS = - = - = - = - = - = - =
	if dataclass.engine == "pboard":
		if method == "set":
			return run_command_with_paste(['pbcopy'], text)
		if method == "get":
			return run_command(['pbpaste'])
	# - = - = - = - = - = - = - = - = - = -
	raise exceptions.UnknownError("Specified engine not found. Have you set it manually?? ]:<")

def init():
	""" Initializes clipman, and detects copy engine for work """
	dataclass.engine = detect_clipboard_engine()
	dataclass.init_called = True

src/clipman/__init__.py
NikitaBeloglazov/clipman
"""
Created for storing version number.
The version number will be placed here based on the tag in Github using the tags_marker.py script.

Having "!!{PLACEHOLDER}!!" in final releases is NOT allowed.
"""
__version__="!!{PLACEHOLDER}!!"

src/clipman/__version__.py
NikitaBeloglazov/clipman
# -*- coding: utf-8 -*-
"""
 * Copyright (C) 2023 Nikita Beloglazov <nnikita.beloglazov@gmail.com>
 *
 * This file is part of github.com/NikitaBeloglazov/clipman.
 *
 * NikitaBeloglazov/clipman is free software; you can redistribute it and/or
 * modify it under the terms of the Mozilla Public License 2.0
 * published by the Mozilla Foundation.
 *
 * NikitaBeloglazov/clipman is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY.
 *
 * You should have received a copy of the Mozilla Public License 2.0
 * along with NikitaBeloglazov/clipman
 * If not, see https://mozilla.org/en-US/MPL/2.0.

- = - =
 * Module Decsription:
 Module that contain exceptions.

- = - =
 * Usage:
 If you want to catch specific error, use name of error, as example:
 - except exceptions.NoEnginesFoundError:

 If you want to catch all errors, use ClipmanBaseException.
 - except exceptions.ClipmanBaseException:
"""

class ClipmanBaseException(Exception):
	""" Used for catch all exceptions in module """

class NoInitializationError(ClipmanBaseException):
	""" Called if dev don't called clipman.init() """
	# - = - = - = - = - = - = - ↓ Set default error message ↓
	def __init__(self, message="Initialization was not been performed or it failed! Call initialization: clipman.init()"):
		super().__init__(message)

class UnsupportedError(ClipmanBaseException):
	""" Called if OS or graphical backend is unsupported """

class TextNotSpecified(ClipmanBaseException):
	""" Called if call("copy") not specified text to paste """

class NoEnginesFoundError(ClipmanBaseException):
	""" If usable clipboard engines not found on target OS """

class EngineError(ClipmanBaseException):
	""" If there is an error raised by clipboard engine """

class EngineTimeoutExpired(ClipmanBaseException):
	""" Called if clipboard engine calling times out. Mostly made for termux-clipboard-get """

class UnknownError(ClipmanBaseException):
	""" No comments """

src/clipman/exceptions.py
NikitaBeloglazov/clipman
# -*- coding: utf-8 -*-
# pylint: disable-all
"""
 * Copyright (C) 2023 Nikita Beloglazov <nnikita.beloglazov@gmail.com>
 *
 * This file is part of github.com/NikitaBeloglazov/clipman.
 *
 * NikitaBeloglazov/clipman is free software; you can redistribute it and/or
 * modify it under the terms of the Mozilla Public License 2.0
 * published by the Mozilla Foundation.
 *
 * NikitaBeloglazov/clipman is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY.
 *
 * You should have received a copy of the Mozilla Public License 2.0
 * along with NikitaBeloglazov/clipman
 * If not, see https://mozilla.org/en-US/MPL/2.0.

- = - =
 * Module Decsription:
 Module that is responsible for supporting OS Windows

- = - =

This piece of code was taken from Pypeclip and essentially rewritten to use classes normally,
the old code was really terrible (function in a function? SERIOUSLY?!?!?!)

LINK: https://github.com/asweigart/pyperclip/blob/master/src/pyperclip/__init__.py#L350

- = -

[!!] I AM (@NikitaBeloglazov) NOT RESPONSIBLE FOR THIS PIECE OF CODE.

This functionality is provided as-is. 
If problems arise, for example, with encoding, God and your own hands will help.
Pull requests are always open. Amen. xD

- = - =
 * Usage:

 - from clipman import windows
 - win = windows.WindowsClipboard() # initialize it

 - win.paste() # returns text in clipboard
 - win.copy("test text") # puts text in clipboard
"""
import contextlib
import ctypes
import time
from ctypes import c_size_t, sizeof, c_wchar_p, get_errno, c_wchar
from ctypes.wintypes import (HGLOBAL, LPVOID, DWORD, LPCSTR, INT, HWND, HINSTANCE, HMENU, BOOL, UINT, HANDLE)
from . import exceptions

class CheckedCall(object):
	def __init__(self, f):
		super(CheckedCall, self).__setattr__("f", f)

	def __call__(self, *args):
		ret = self.f(*args)
		if not ret and get_errno():
			raise exceptions.EngineError("WindowsError: Error calling " + self.f.__name__)
		return ret

	def __setattr__(self, key, value):
		setattr(self.f, key, value)


class WindowsClipboard():
	def __init__(self):
		windll = ctypes.windll
		msvcrt = ctypes.CDLL('msvcrt')

		self.safeCreateWindowExA = CheckedCall(windll.user32.CreateWindowExA)
		self.safeCreateWindowExA.argtypes = [DWORD, LPCSTR, LPCSTR, DWORD, INT, INT,
										INT, INT, HWND, HMENU, HINSTANCE, LPVOID]
		self.safeCreateWindowExA.restype = HWND

		self.safeDestroyWindow = CheckedCall(windll.user32.DestroyWindow)
		self.safeDestroyWindow.argtypes = [HWND]
		self.safeDestroyWindow.restype = BOOL

		self.OpenClipboard = windll.user32.OpenClipboard
		self.OpenClipboard.argtypes = [HWND]
		self.OpenClipboard.restype = BOOL

		self.safeCloseClipboard = CheckedCall(windll.user32.CloseClipboard)
		self.safeCloseClipboard.argtypes = []
		self.safeCloseClipboard.restype = BOOL

		self.safeEmptyClipboard = CheckedCall(windll.user32.EmptyClipboard)
		self.safeEmptyClipboard.argtypes = []
		self.safeEmptyClipboard.restype = BOOL

		self.safeGetClipboardData = CheckedCall(windll.user32.GetClipboardData)
		self.safeGetClipboardData.argtypes = [UINT]
		self.safeGetClipboardData.restype = HANDLE

		self.safeSetClipboardData = CheckedCall(windll.user32.SetClipboardData)
		self.safeSetClipboardData.argtypes = [UINT, HANDLE]
		self.safeSetClipboardData.restype = HANDLE

		self.safeGlobalAlloc = CheckedCall(windll.kernel32.GlobalAlloc)
		self.safeGlobalAlloc.argtypes = [UINT, c_size_t]
		self.safeGlobalAlloc.restype = HGLOBAL

		self.safeGlobalLock = CheckedCall(windll.kernel32.GlobalLock)
		self.safeGlobalLock.argtypes = [HGLOBAL]
		self.safeGlobalLock.restype = LPVOID

		self.safeGlobalUnlock = CheckedCall(windll.kernel32.GlobalUnlock)
		self.safeGlobalUnlock.argtypes = [HGLOBAL]
		self.safeGlobalUnlock.restype = BOOL

		self.wcslen = CheckedCall(msvcrt.wcslen)
		self.wcslen.argtypes = [c_wchar_p]
		self.wcslen.restype = UINT

		self.GMEM_MOVEABLE = 0x0002
		self.CF_UNICODETEXT = 13

	@contextlib.contextmanager
	def window(self):
		"""
		Context that provides a valid Windows hwnd.
		"""
		# we really just need the hwnd, so setting "STATIC"
		# as predefined lpClass is just fine.
		hwnd = self.safeCreateWindowExA(0, b"STATIC", None, 0, 0, 0, 0, 0,
								   None, None, None, None)
		try:
			yield hwnd
		finally:
			self.safeDestroyWindow(hwnd)

	@contextlib.contextmanager
	def clipboard(self, hwnd):
		"""
		Context manager that opens the clipboard and prevents
		other applications from modifying the clipboard 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 = self.OpenClipboard(hwnd)
			if success:
				break
			time.sleep(0.01)
		if not success:
			raise exceptions.EngineError("WindowsError: Error calling OpenClipboard")

		try:
			yield
		finally:
			self.safeCloseClipboard()

	def copy(self, text):
		"""
		This function is heavily based on
		http://msdn.com/ms649016#_win32_Copying_Information_to_the_Clipboard
		"""

		text = str(text)
		#text = _stringifyText(text) # Converts non-str values to str.

		with self.window() as 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.
			with self.clipboard(hwnd):
				self.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 = self.wcslen(text) + 1
					handle = self.safeGlobalAlloc(self.GMEM_MOVEABLE,
											 count * sizeof(c_wchar))
					locked_handle = self.safeGlobalLock(handle)

					ctypes.memmove(c_wchar_p(locked_handle), c_wchar_p(text), count * sizeof(c_wchar))

					self.safeGlobalUnlock(handle)
					self.safeSetClipboardData(self.CF_UNICODETEXT, handle)

	def paste(self):
		with self.clipboard(None):
			handle = self.safeGetClipboardData(self.CF_UNICODETEXT)
			if not handle:
				# GetClipboardData may return NULL with errno == NO_ERROR
				# if the clipboard is empty.
				# (Also, it may return a handle to an empty buffer,
				# but technically that's not empty)
				return ""
			return c_wchar_p(handle).value

src/clipman/windows.py
NikitaBeloglazov/clipman
import subprocess
import os

os.system("tree -a")
os.system("git fetch --tags")
print(subprocess.check_output("git tag -n9", shell=True, encoding="UTF-8"))
tag = subprocess.check_output("git describe --tags", shell=True, encoding="UTF-8")

print("[TAG MARKER] git response: " + tag)
tag = tag.replace("\n", "").replace("v", "")
if tag.find("-") > 1:
	tag = tag[0:tag.find("-")]
print("[TAG MARKER] FOUND TAG: " + tag)

print("[TAG MARKER] MARKING VERSION IN pyproject.toml")
# Open the file for reading and writing (the 'r+' mode provides both read and write access)
with open('pyproject.toml', 'r+') as file:
	# Reading the contents of a file
	content = file.read()
	# Replace the desired part of the text using the replace method
	new_content = content.replace('!!{PLACEHOLDER}!!', tag)
	# Move the file pointer to the beginning (0) to overwrite
	file.seek(0)
	# Write the changed content back to the file
	file.write(new_content)
	# Trim the file if the new content is shorter than the old one to remove the remaining data
	file.truncate()
print("[TAG MARKER] DONE")

print("[TAG MARKER] MARKING VERSION IN src/clipman/__version__.py")
with open('src/clipman/__version__.py', 'r+') as file:
	# Reading the contents of a file
	content = file.read()
	# Replace the desired part of the text using the replace method
	new_content = content.replace('!!{PLACEHOLDER}!!', tag)
	# Move the file pointer to the beginning (0) to overwrite
	file.seek(0)
	# Write the changed content back to the file
	file.write(new_content)
	# Trim the file if the new content is shorter than the old one to remove the remaining data
	file.truncate()
print("[TAG MARKER] DONE")

print("[TAG MARKER] Work finished")

tags_marker.py
NikitaBeloglazov/clipman