From 27f18f970659de1cc31b7927fad0dbf0867a28f0 Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Mon, 1 May 2017 18:26:54 -0400 Subject: [PATCH 01/37] Copied platform manager for OS X --- lackey/PlatformManagerOSX.py | 688 +++++++++++++++++++++++++++++++ lackey/PlatformManagerWindows.py | 2 +- 2 files changed, 689 insertions(+), 1 deletion(-) create mode 100644 lackey/PlatformManagerOSX.py diff --git a/lackey/PlatformManagerOSX.py b/lackey/PlatformManagerOSX.py new file mode 100644 index 0000000..ff62306 --- /dev/null +++ b/lackey/PlatformManagerOSX.py @@ -0,0 +1,688 @@ +""" Platform-specific code for Windows is encapsulated in this module. """ + +import os +import re +import time +import numpy +import ctypes +try: + import Tkinter as tk +except ImportError: + import tkinter as tk +from ctypes import wintypes +from PIL import Image, ImageTk, ImageOps + +from .Settings import Debug +from .InputEmulation import Keyboard + +# Python 3 compatibility +try: + basestring +except NameError: + basestring = str + +class PlatformManagerOSX(object): + """ Abstracts OSX-specific OS-level features """ + def __init__(self): + #self._root = tk.Tk() + #self._root.overrideredirect(1) + #self._root.withdraw() + user32 = ctypes.WinDLL('user32', use_last_error=True) + gdi32 = ctypes.WinDLL('gdi32', use_last_error=True) + kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) + psapi = ctypes.WinDLL('psapi', use_last_error=True) + self._user32 = user32 + self._gdi32 = gdi32 + self._kernel32 = kernel32 + self._psapi = psapi + + # Pay attention to different screen DPI settings + self._user32.SetProcessDPIAware() + + # Mapping to `keyboard` names + self._SPECIAL_KEYCODES = { + "BACKSPACE": "backspace", + "TAB": "tab", + "CLEAR": "clear", + "ENTER": "enter", + "SHIFT": "shift", + "CTRL": "ctrl", + "ALT": "alt", + "PAUSE": "pause", + "CAPS_LOCK": "caps lock", + "ESC": "esc", + "SPACE": "spacebar", + "PAGE_UP": "page up", + "PAGE_DOWN": "page down", + "END": "end", + "HOME": "home", + "LEFT": "left arrow", + "UP": "up arrow", + "RIGHT": "right arrow", + "DOWN": "down arrow", + "SELECT": "select", + "PRINT": "print", + "PRINTSCREEN": "print screen", + "INSERT": "ins", + "DELETE": "del", + "WIN": "win", + "CMD": "win", + "META": "win", + "NUM0": "keypad 0", + "NUM1": "keypad 1", + "NUM2": "keypad 2", + "NUM3": "keypad 3", + "NUM4": "keypad 4", + "NUM5": "keypad 5", + "NUM6": "keypad 6", + "NUM7": "keypad 7", + "NUM8": "keypad 8", + "NUM9": "keypad 9", + "NUM9": "keypad 9", + "SEPARATOR": 83, + "ADD": 78, + "MINUS": 74, + "MULTIPLY": 55, + "DIVIDE": 53, + "F1": "f1", + "F2": "f2", + "F3": "f3", + "F4": "f4", + "F5": "f5", + "F6": "f6", + "F7": "f7", + "F8": "f8", + "F9": "f9", + "F10": "f10", + "F11": "f11", + "F12": "f12", + "F13": "f13", + "F14": "f14", + "F15": "f15", + "F16": "f16", + "NUM_LOCK": "num lock", + "SCROLL_LOCK": "scroll lock", + } + self._REGULAR_KEYCODES = { + "0": "0", + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5", + "6": "6", + "7": "7", + "8": "8", + "9": "9", + "a": "a", + "b": "b", + "c": "c", + "d": "d", + "e": "e", + "f": "f", + "g": "g", + "h": "h", + "i": "i", + "j": "j", + "k": "k", + "l": "l", + "m": "m", + "n": "n", + "o": "o", + "p": "p", + "q": "q", + "r": "r", + "s": "s", + "t": "t", + "u": "u", + "v": "v", + "w": "w", + "x": "x", + "y": "y", + "z": "z", + ";": ";", + "=": "=", + ",": ",", + "-": "-", + ".": ".", + "/": "/", + "`": "`", + "[": "[", + "\\": "\\", + "]": "]", + "'": "'", + " ": " ", + } + self._UPPERCASE_KEYCODES = { + "~": "`", + "+": "=", + ")": "0", + "!": "1", + "@": "2", + "#": "3", + "$": "4", + "%": "5", + "^": "6", + "&": "7", + "*": "8", + "(": "9", + "A": "a", + "B": "b", + "C": "c", + "D": "d", + "E": "e", + "F": "f", + "G": "g", + "H": "h", + "I": "i", + "J": "j", + "K": "k", + "L": "l", + "M": "m", + "N": "n", + "O": "o", + "P": "p", + "Q": "q", + "R": "r", + "S": "s", + "T": "t", + "U": "u", + "V": "v", + "W": "w", + "X": "x", + "Y": "y", + "Z": "z", + ":": ";", + "<": ",", + "_": "-", + ">": ".", + "?": "/", + "|": "\\", + "\"": "'", + "{": "[", + "}": "]", + } + + def _check_count(self, result, func, args): + #pylint: disable=unused-argument + """ Private function to return ctypes errors cleanly """ + if result == 0: + raise ctypes.WinError(ctypes.get_last_error()) + return args + + ## Screen functions + + def getBitmapFromRect(self, x, y, w, h): + """ Capture the specified area of the (virtual) screen. """ + min_x, min_y, screen_width, screen_height = self._getVirtualScreenRect() + img = self._getVirtualScreenBitmap() + # Limit the coordinates to the virtual screen + # Then offset so 0,0 is the top left corner of the image + # (Top left of virtual screen could be negative) + x1 = min(max(min_x, x), min_x+screen_width) - min_x + y1 = min(max(min_y, y), min_y+screen_height) - min_y + x2 = min(max(min_x, x+w), min_x+screen_width) - min_x + y2 = min(max(min_y, y+h), min_y+screen_height) - min_y + return numpy.array(img.crop((x1, y1, x2, y2))) + def getScreenBounds(self, screenId): + """ Returns the screen size of the specified monitor (0 being the main monitor). """ + screen_details = self.getScreenDetails() + if not isinstance(screenId, int) or screenId < -1 or screenId >= len(screen_details): + raise ValueError("Invalid screen ID") + if screenId == -1: + # -1 represents the entire virtual screen + x1, y1, x2, y2 = self._getVirtualScreenRect() + return (x1, y1, x2-x1, y2-y1) + return screen_details[screenId]["rect"] + def getScreenDetails(self): + """ Return list of attached monitors + + For each monitor (as dict), ``monitor["rect"]`` represents the screen as positioned + in virtual screen. List is returned in device order, with the first element (0) + representing the primary monitor. + """ + monitors = self._getMonitorInfo() + primary_screen = None + screens = [] + for monitor in monitors: + # Convert screen rect to Lackey-style rect (x,y,w,h) as position in virtual screen + screen = { + "rect": ( + monitor["rect"][0], + monitor["rect"][1], + monitor["rect"][2] - monitor["rect"][0], + monitor["rect"][3] - monitor["rect"][1] + ) + } + screens.append(screen) + return screens + def isPointVisible(self, x, y): + """ Checks if a point is visible on any monitor. """ + class POINT(ctypes.Structure): + _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] + pt = POINT() + pt.x = x + pt.y = y + MONITOR_DEFAULTTONULL = 0 + hmon = self._user32.MonitorFromPoint(pt, MONITOR_DEFAULTTONULL) + if hmon == 0: + return False + return True + def _captureScreen(self, device_name): + """ Captures a bitmap from the given monitor device name + + Returns as a PIL Image (BGR rather than RGB, for compatibility with OpenCV) + """ + + ## Define constants/structs + class HBITMAP(ctypes.Structure): + _fields_ = [("bmType", ctypes.c_long), + ("bmWidth", ctypes.c_long), + ("bmHeight", ctypes.c_long), + ("bmWidthBytes", ctypes.c_long), + ("bmPlanes", ctypes.wintypes.WORD), + ("bmBitsPixel", ctypes.wintypes.WORD), + ("bmBits", ctypes.wintypes.LPVOID)] + class BITMAPINFOHEADER(ctypes.Structure): + _fields_ = [("biSize", ctypes.wintypes.DWORD), + ("biWidth", ctypes.c_long), + ("biHeight", ctypes.c_long), + ("biPlanes", ctypes.wintypes.WORD), + ("biBitCount", ctypes.wintypes.WORD), + ("biCompression", ctypes.wintypes.DWORD), + ("biSizeImage", ctypes.wintypes.DWORD), + ("biXPelsPerMeter", ctypes.c_long), + ("biYPelsPerMeter", ctypes.c_long), + ("biClrUsed", ctypes.wintypes.DWORD), + ("biClrImportant", ctypes.wintypes.DWORD)] + class BITMAPINFO(ctypes.Structure): + _fields_ = [("bmiHeader", BITMAPINFOHEADER), + ("bmiColors", ctypes.wintypes.DWORD*3)] + HORZRES = ctypes.c_int(8) + VERTRES = ctypes.c_int(10) + SRCCOPY = 0x00CC0020 + CAPTUREBLT = 0x40000000 + DIB_RGB_COLORS = 0 + + ## Begin logic + self._gdi32.CreateDCA.restype = ctypes.c_void_p + hdc = self._gdi32.CreateDCA(ctypes.c_char_p(device_name.encode("utf-8")), 0, 0, 0) # Convert to bytestring for c_char_p type + if hdc == 0: + raise ValueError("Empty hdc provided") + + # Get monitor specs + self._gdi32.GetDeviceCaps.argtypes = [ctypes.c_void_p, ctypes.c_int] + screen_width = self._gdi32.GetDeviceCaps(hdc, HORZRES) + screen_height = self._gdi32.GetDeviceCaps(hdc, VERTRES) + + # Create memory device context for monitor + self._gdi32.CreateCompatibleDC.restype = ctypes.c_void_p + self._gdi32.CreateCompatibleDC.argtypes = [ctypes.c_void_p] + hCaptureDC = self._gdi32.CreateCompatibleDC(hdc) + if hCaptureDC == 0: + raise WindowsError("gdi:CreateCompatibleDC failed") + + # Create bitmap compatible with monitor + self._gdi32.CreateCompatibleBitmap.restype = ctypes.c_void_p + self._gdi32.CreateCompatibleBitmap.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int] + hCaptureBmp = self._gdi32.CreateCompatibleBitmap(hdc, screen_width, screen_height) + if hCaptureBmp == 0: + raise WindowsError("gdi:CreateCompatibleBitmap failed") + + # Select hCaptureBmp into hCaptureDC device context + self._gdi32.SelectObject.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + self._gdi32.SelectObject(hCaptureDC, hCaptureBmp) + + # Perform bit-block transfer from screen to device context (and thereby hCaptureBmp) + self._gdi32.BitBlt.argtypes = [ + ctypes.c_void_p, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_int, + ctypes.c_void_p, + ctypes.c_int, + ctypes.c_int, + ctypes.c_ulong + ] + self._gdi32.BitBlt(hCaptureDC, 0, 0, screen_width, screen_height, hdc, 0, 0, SRCCOPY | CAPTUREBLT) + + # Capture image bits from bitmap + img_info = BITMAPINFO() + img_info.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) + img_info.bmiHeader.biWidth = screen_width + img_info.bmiHeader.biHeight = screen_height + img_info.bmiHeader.biPlanes = 1 + img_info.bmiHeader.biBitCount = 32 + img_info.bmiHeader.biCompression = 0 + img_info.bmiHeader.biClrUsed = 0 + img_info.bmiHeader.biClrImportant = 0 + + buffer_length = screen_width * 4 * screen_height + image_data = ctypes.create_string_buffer(buffer_length) + + self._gdi32.GetDIBits.restype = ctypes.c_int + self._gdi32.GetDIBits.argtypes = [ + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_uint, + ctypes.c_uint, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.c_uint + ] + scanlines = self._gdi32.GetDIBits( + hCaptureDC, + hCaptureBmp, + 0, + screen_height, + ctypes.byref(image_data), + ctypes.byref(img_info), + DIB_RGB_COLORS) + if scanlines != screen_height: + raise WindowsError("gdi:GetDIBits failed") + final_image = ImageOps.flip( + Image.frombuffer( + "RGBX", + (screen_width, screen_height), + image_data, + "raw", + "RGBX", + 0, + 1)) + # Destroy created device context & GDI bitmap + self._gdi32.DeleteObject.argtypes = [ctypes.c_void_p] + self._gdi32.DeleteObject(hdc) + self._gdi32.DeleteObject(hCaptureDC) + self._gdi32.DeleteObject(hCaptureBmp) + return final_image + def _getMonitorInfo(self): + """ Returns info about the attached monitors, in device order + + [0] is always the primary monitor + """ + monitors = [] + CCHDEVICENAME = 32 + def _MonitorEnumProcCallback(hMonitor, hdcMonitor, lprcMonitor, dwData): + class MONITORINFOEX(ctypes.Structure): + _fields_ = [("cbSize", ctypes.wintypes.DWORD), + ("rcMonitor", ctypes.wintypes.RECT), + ("rcWork", ctypes.wintypes.RECT), + ("dwFlags", ctypes.wintypes.DWORD), + ("szDevice", ctypes.wintypes.WCHAR*CCHDEVICENAME)] + lpmi = MONITORINFOEX() + lpmi.cbSize = ctypes.sizeof(MONITORINFOEX) + self._user32.GetMonitorInfoW(hMonitor, ctypes.byref(lpmi)) + #hdc = self._gdi32.CreateDCA(ctypes.c_char_p(lpmi.szDevice), 0, 0, 0) + monitors.append({ + "hmon": hMonitor, + #"hdc": hdc, + "rect": (lprcMonitor.contents.left, + lprcMonitor.contents.top, + lprcMonitor.contents.right, + lprcMonitor.contents.bottom), + "name": lpmi.szDevice + }) + return True + MonitorEnumProc = ctypes.WINFUNCTYPE( + ctypes.c_bool, + ctypes.c_ulong, + ctypes.c_ulong, + ctypes.POINTER(ctypes.wintypes.RECT), + ctypes.c_int) + callback = MonitorEnumProc(_MonitorEnumProcCallback) + if self._user32.EnumDisplayMonitors(0, 0, callback, 0) == 0: + raise WindowsError("Unable to enumerate monitors") + # Clever magic to make the screen with origin of (0,0) [the primary monitor] + # the first in the list + # Sort by device ID - 0 is primary, 1 is next, etc. + monitors.sort(key=lambda x: (not (x["rect"][0] == 0 and x["rect"][1] == 0), x["name"])) + + return monitors + def _getVirtualScreenRect(self): + """ The virtual screen is the bounding box containing all monitors. + + Not all regions in the virtual screen are actually visible. The (0,0) coordinate + is the top left corner of the primary screen rather than the whole bounding box, so + some regions of the virtual screen may have negative coordinates if another screen + is positioned in Windows as further to the left or above the primary screen. + + Returns the rect as (x, y, w, h) + """ + SM_XVIRTUALSCREEN = 76 # Left of virtual screen + SM_YVIRTUALSCREEN = 77 # Top of virtual screen + SM_CXVIRTUALSCREEN = 78 # Width of virtual screen + SM_CYVIRTUALSCREEN = 79 # Heigiht of virtual screen + + return (self._user32.GetSystemMetrics(SM_XVIRTUALSCREEN), \ + self._user32.GetSystemMetrics(SM_YVIRTUALSCREEN), \ + self._user32.GetSystemMetrics(SM_CXVIRTUALSCREEN), \ + self._user32.GetSystemMetrics(SM_CYVIRTUALSCREEN)) + def _getVirtualScreenBitmap(self): + """ Returns a PIL bitmap (BGR channel order) of all monitors + + Arranged like the Virtual Screen + """ + + # Collect information about the virtual screen & monitors + min_x, min_y, screen_width, screen_height = self._getVirtualScreenRect() + monitors = self._getMonitorInfo() + + # Initialize new black image the size of the virtual screen + virt_screen = Image.new("RGB", (screen_width, screen_height)) + + # Capture images of each of the monitors and overlay on the virtual screen + for monitor_id in range(0, len(monitors)): + img = self._captureScreen(monitors[monitor_id]["name"]) + # Capture virtscreen coordinates of monitor + x1, y1, x2, y2 = monitors[monitor_id]["rect"] + # Convert to image-local coordinates + x = x1 - min_x + y = y1 - min_y + # Paste on the virtual screen + virt_screen.paste(img, (x, y)) + return virt_screen + + ## Clipboard functions + + def osCopy(self): + """ Triggers the OS "copy" keyboard shortcut """ + k = Keyboard() + k.keyDown("{CTRL}") + k.type("c") + k.keyUp("{CTRL}") + def osPaste(self): + """ Triggers the OS "paste" keyboard shortcut """ + k = Keyboard() + k.keyDown("{CTRL}") + k.type("v") + k.keyUp("{CTRL}") + + ## Window functions + + def getWindowByTitle(self, wildcard, order=0): + """ Returns a handle for the first window that matches the provided "wildcard" regex """ + EnumWindowsProc = ctypes.WINFUNCTYPE( + ctypes.c_bool, + ctypes.POINTER(ctypes.c_int), + ctypes.py_object) + def callback(hwnd, context): + if ctypes.windll.user32.IsWindowVisible(hwnd): + length = ctypes.windll.user32.GetWindowTextLengthW(hwnd) + buff = ctypes.create_unicode_buffer(length + 1) + ctypes.windll.user32.GetWindowTextW(hwnd, buff, length + 1) + if re.search(context["wildcard"], buff.value) != None and not context["handle"]: + if context["order"] > 0: + context["order"] -= 1 + else: + context["handle"] = hwnd + return True + data = {"wildcard": wildcard, "handle": None, "order": order} + ctypes.windll.user32.EnumWindows(EnumWindowsProc(callback), ctypes.py_object(data)) + return data["handle"] + def getWindowByPID(self, pid, order=0): + """ Returns a handle for the first window that matches the provided PID """ + if pid <= 0: + return None + EnumWindowsProc = ctypes.WINFUNCTYPE( + ctypes.c_bool, + ctypes.POINTER(ctypes.c_int), + ctypes.py_object) + def callback(hwnd, context): + if ctypes.windll.user32.IsWindowVisible(hwnd): + pid = ctypes.c_long() + ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + if context["pid"] == int(pid.value) and not context["handle"]: + if context["order"] > 0: + context["order"] -= 1 + else: + context["handle"] = hwnd + return True + data = {"pid": pid, "handle": None, "order": order} + ctypes.windll.user32.EnumWindows(EnumWindowsProc(callback), ctypes.py_object(data)) + return data["handle"] + def getWindowRect(self, hwnd): + """ Returns a rect (x,y,w,h) for the specified window's area """ + rect = ctypes.wintypes.RECT() + if ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)): + x1 = rect.left + y1 = rect.top + x2 = rect.right + y2 = rect.bottom + return (x1, y1, x2-x1, y2-y1) + return None + def focusWindow(self, hwnd): + """ Brings specified window to the front """ + Debug.log(3, "Focusing window: " + str(hwnd)) + SW_RESTORE = 9 + if ctypes.windll.user32.IsIconic(hwnd): + ctypes.windll.user32.ShowWindow(hwnd, SW_RESTORE) + ctypes.windll.user32.SetForegroundWindow(hwnd) + def getWindowTitle(self, hwnd): + """ Gets the title for the specified window """ + length = ctypes.windll.user32.GetWindowTextLengthW(hwnd) + buff = ctypes.create_unicode_buffer(length + 1) + ctypes.windll.user32.GetWindowTextW(hwnd, buff, length + 1) + return buff.value + def getWindowPID(self, hwnd): + """ Gets the process ID that the specified window belongs to """ + pid = ctypes.c_long() + ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + return int(pid.value) + def getForegroundWindow(self): + """ Returns a handle to the window in the foreground """ + return self._user32.GetForegroundWindow() + + ## Highlighting functions + + def highlight(self, rect, seconds=1): + """ Simulates a transparent rectangle over the specified ``rect`` on the screen. + + Actually takes a screenshot of the region and displays with a + rectangle border in a borderless window (due to Tkinter limitations) + + If a Tkinter root window has already been created somewhere else, + uses that instead of creating a new one. + """ + if tk._default_root is None: + Debug.log(3, "Creating new temporary Tkinter root") + temporary_root = True + root = tk.Tk() + root.withdraw() + else: + Debug.log(3, "Borrowing existing Tkinter root") + temporary_root = False + root = tk._default_root + image_to_show = self.getBitmapFromRect(*rect) + app = highlightWindow(root, rect, image_to_show) + timeout = time.time()+seconds + while time.time() < timeout: + app.update_idletasks() + app.update() + app.destroy() + if temporary_root: + root.destroy() + + ## Process functions + def isPIDValid(self, pid): + """ Checks if a PID is associated with a running process """ + ## Slightly copied wholesale from http://stackoverflow.com/questions/568271/how-to-check-if-there-exists-a-process-with-a-given-pid + ## Thanks to http://stackoverflow.com/users/1777162/ntrrgc and http://stackoverflow.com/users/234270/speedplane + class ExitCodeProcess(ctypes.Structure): + _fields_ = [('hProcess', ctypes.c_void_p), + ('lpExitCode', ctypes.POINTER(ctypes.c_ulong))] + SYNCHRONIZE = 0x100000 + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + process = self._kernel32.OpenProcess(SYNCHRONIZE|PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) + if not process: + return False + ec = ExitCodeProcess() + out = self._kernel32.GetExitCodeProcess(process, ctypes.byref(ec)) + if not out: + err = self._kernel32.GetLastError() + if self._kernel32.GetLastError() == 5: + # Access is denied. + logging.warning("Access is denied to get pid info.") + self._kernel32.CloseHandle(process) + return False + elif bool(ec.lpExitCode): + # There is an exit code, it quit + self._kernel32.CloseHandle(process) + return False + # No exit code, it's running. + self._kernel32.CloseHandle(process) + return True + def killProcess(self, pid): + """ Kills the process with the specified PID (if possible) """ + SYNCHRONIZE = 0x00100000 + PROCESS_TERMINATE = 0x0001 + hProcess = self._kernel32.OpenProcess(SYNCHRONIZE|PROCESS_TERMINATE, True, pid) + result = self._kernel32.TerminateProcess(hProcess, 0) + self._kernel32.CloseHandle(hProcess) + def getProcessName(self, pid): + if pid <= 0: + return "" + MAX_PATH_LEN = 2048 + proc_name = ctypes.create_string_buffer(MAX_PATH_LEN) + PROCESS_VM_READ = 0x0010 + PROCESS_QUERY_INFORMATION = 0x0400 + hProcess = self._kernel32.OpenProcess(PROCESS_VM_READ|PROCESS_QUERY_INFORMATION, 0, pid) + #self._psapi.GetProcessImageFileName.restype = ctypes.wintypes.DWORD + self._psapi.GetModuleFileNameExA(hProcess, 0, ctypes.byref(proc_name), MAX_PATH_LEN) + return os.path.basename(proc_name.value.decode("utf-8")) + +## Helper class for highlighting + +class highlightWindow(tk.Toplevel): + def __init__(self, root, rect, screen_cap): + """ Accepts rect as (x,y,w,h) """ + self.root = root + tk.Toplevel.__init__(self, self.root, bg="red", bd=0) + + ## Set toplevel geometry, remove borders, and push to the front + self.geometry("{2}x{3}+{0}+{1}".format(*rect)) + self.overrideredirect(1) + self.attributes("-topmost", True) + + ## Create canvas and fill it with the provided image. Then draw rectangle outline + self.canvas = tk.Canvas( + self, + width=rect[2], + height=rect[3], + bd=0, + bg="blue", + highlightthickness=0) + self.tk_image = ImageTk.PhotoImage(Image.fromarray(screen_cap[..., [2, 1, 0]])) + self.canvas.create_image(0, 0, image=self.tk_image, anchor=tk.NW) + self.canvas.create_rectangle( + 2, + 2, + rect[2]-2, + rect[3]-2, + outline="red", + width=4) + self.canvas.pack(fill=tk.BOTH, expand=tk.YES) + + ## Lift to front if necessary and refresh. + self.lift() + self.update() diff --git a/lackey/PlatformManagerWindows.py b/lackey/PlatformManagerWindows.py index e1c4ae2..f844f41 100644 --- a/lackey/PlatformManagerWindows.py +++ b/lackey/PlatformManagerWindows.py @@ -22,7 +22,7 @@ basestring = str class PlatformManagerWindows(object): - """ Abstracts Windows-specific OS-level features like mouse/keyboard control """ + """ Abstracts Windows-specific OS-level features """ def __init__(self): #self._root = tk.Tk() #self._root.overrideredirect(1) From 8028d25bdf0076e28dfb541eb169d5321e3a610f Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Mon, 1 May 2017 20:08:22 -0400 Subject: [PATCH 02/37] Rewriting code for OSX --- .gitignore | 2 + lackey/PlatformManagerOSX.py | 299 +++++-------------------------- lackey/PlatformManagerWindows.py | 5 +- 3 files changed, 49 insertions(+), 257 deletions(-) diff --git a/.gitignore b/.gitignore index dd1d722..5eabb03 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,5 @@ Network Trash Folder Temporary Items .apdisk logfile.txt + +.vscode/settings.json diff --git a/lackey/PlatformManagerOSX.py b/lackey/PlatformManagerOSX.py index ff62306..591360a 100644 --- a/lackey/PlatformManagerOSX.py +++ b/lackey/PlatformManagerOSX.py @@ -24,20 +24,6 @@ class PlatformManagerOSX(object): """ Abstracts OSX-specific OS-level features """ def __init__(self): - #self._root = tk.Tk() - #self._root.overrideredirect(1) - #self._root.withdraw() - user32 = ctypes.WinDLL('user32', use_last_error=True) - gdi32 = ctypes.WinDLL('gdi32', use_last_error=True) - kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) - psapi = ctypes.WinDLL('psapi', use_last_error=True) - self._user32 = user32 - self._gdi32 = gdi32 - self._kernel32 = kernel32 - self._psapi = psapi - - # Pay attention to different screen DPI settings - self._user32.SetProcessDPIAware() # Mapping to `keyboard` names self._SPECIAL_KEYCODES = { @@ -203,19 +189,12 @@ def __init__(self): "}": "]", } - def _check_count(self, result, func, args): - #pylint: disable=unused-argument - """ Private function to return ctypes errors cleanly """ - if result == 0: - raise ctypes.WinError(ctypes.get_last_error()) - return args - ## Screen functions def getBitmapFromRect(self, x, y, w, h): """ Capture the specified area of the (virtual) screen. """ min_x, min_y, screen_width, screen_height = self._getVirtualScreenRect() - img = self._getVirtualScreenBitmap() + img = self._getVirtualScreenBitmap() # TODO # Limit the coordinates to the virtual screen # Then offset so 0,0 is the top left corner of the image # (Top left of virtual screen could be negative) @@ -231,9 +210,41 @@ def getScreenBounds(self, screenId): raise ValueError("Invalid screen ID") if screenId == -1: # -1 represents the entire virtual screen - x1, y1, x2, y2 = self._getVirtualScreenRect() - return (x1, y1, x2-x1, y2-y1) + return self._getVirtualScreenRect() return screen_details[screenId]["rect"] + def _getVirtualScreenRect(self): + """ Returns the rect of all attached screens as (x, y, w, h) """ + monitors = self.getScreenDetails() + x1 = min([s["rect"][0] for s in monitors]) + y1 = min([s["rect"][1] for s in monitors]) + x2 = max([s["rect"][0]+s["rect"][3] for s in monitors]) + y2 = max([s["rect"][1]+s["rect"][4] for s in monitors]) + return (x1, y1, x2-x1, y2-y1) + def _getVirtualScreenBitmap(self): + """ Returns a bitmap of all attached screens """ + filenames = [] + screen_details = self.getScreenDetails() + for screen in screen_details: + fh, filepath = tempfile.mkstemp('.png') + filenames.append(filepath) + os.close(fh) + subprocess.call(['screencapture', '-x', *filenames]) + + min_x, min_y, screen_w, screen_h = self._getVirtualScreenRect() + virtual_screen = Image.new("RGB", (screen_w, screen_h)) + for filename, screen in zip(filenames, screen_details): + im = Image.open(filename) + im.load() + # Capture virtscreen coordinates of monitor + x, y, w, h = screen["rect"] + # Convert to image-local coordinates + x = x - min_x + y = y - min_y + # Paste on the virtual screen + virtual_screen.paste(im, (x, y)) + os.unlink(filename) + + def getScreenDetails(self): """ Return list of attached monitors @@ -241,247 +252,27 @@ def getScreenDetails(self): in virtual screen. List is returned in device order, with the first element (0) representing the primary monitor. """ - monitors = self._getMonitorInfo() primary_screen = None screens = [] - for monitor in monitors: + for monitor in AppKit.NSScreen.screens(): # Convert screen rect to Lackey-style rect (x,y,w,h) as position in virtual screen screen = { "rect": ( - monitor["rect"][0], - monitor["rect"][1], - monitor["rect"][2] - monitor["rect"][0], - monitor["rect"][3] - monitor["rect"][1] + monitor.frame().origin.x, + monitor.frame().origin.y, + monitor.frame().size.width, + monitor.frame().size.height ) } screens.append(screen) return screens def isPointVisible(self, x, y): """ Checks if a point is visible on any monitor. """ - class POINT(ctypes.Structure): - _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)] - pt = POINT() - pt.x = x - pt.y = y - MONITOR_DEFAULTTONULL = 0 - hmon = self._user32.MonitorFromPoint(pt, MONITOR_DEFAULTTONULL) - if hmon == 0: - return False - return True - def _captureScreen(self, device_name): - """ Captures a bitmap from the given monitor device name - - Returns as a PIL Image (BGR rather than RGB, for compatibility with OpenCV) - """ - - ## Define constants/structs - class HBITMAP(ctypes.Structure): - _fields_ = [("bmType", ctypes.c_long), - ("bmWidth", ctypes.c_long), - ("bmHeight", ctypes.c_long), - ("bmWidthBytes", ctypes.c_long), - ("bmPlanes", ctypes.wintypes.WORD), - ("bmBitsPixel", ctypes.wintypes.WORD), - ("bmBits", ctypes.wintypes.LPVOID)] - class BITMAPINFOHEADER(ctypes.Structure): - _fields_ = [("biSize", ctypes.wintypes.DWORD), - ("biWidth", ctypes.c_long), - ("biHeight", ctypes.c_long), - ("biPlanes", ctypes.wintypes.WORD), - ("biBitCount", ctypes.wintypes.WORD), - ("biCompression", ctypes.wintypes.DWORD), - ("biSizeImage", ctypes.wintypes.DWORD), - ("biXPelsPerMeter", ctypes.c_long), - ("biYPelsPerMeter", ctypes.c_long), - ("biClrUsed", ctypes.wintypes.DWORD), - ("biClrImportant", ctypes.wintypes.DWORD)] - class BITMAPINFO(ctypes.Structure): - _fields_ = [("bmiHeader", BITMAPINFOHEADER), - ("bmiColors", ctypes.wintypes.DWORD*3)] - HORZRES = ctypes.c_int(8) - VERTRES = ctypes.c_int(10) - SRCCOPY = 0x00CC0020 - CAPTUREBLT = 0x40000000 - DIB_RGB_COLORS = 0 - - ## Begin logic - self._gdi32.CreateDCA.restype = ctypes.c_void_p - hdc = self._gdi32.CreateDCA(ctypes.c_char_p(device_name.encode("utf-8")), 0, 0, 0) # Convert to bytestring for c_char_p type - if hdc == 0: - raise ValueError("Empty hdc provided") - - # Get monitor specs - self._gdi32.GetDeviceCaps.argtypes = [ctypes.c_void_p, ctypes.c_int] - screen_width = self._gdi32.GetDeviceCaps(hdc, HORZRES) - screen_height = self._gdi32.GetDeviceCaps(hdc, VERTRES) - - # Create memory device context for monitor - self._gdi32.CreateCompatibleDC.restype = ctypes.c_void_p - self._gdi32.CreateCompatibleDC.argtypes = [ctypes.c_void_p] - hCaptureDC = self._gdi32.CreateCompatibleDC(hdc) - if hCaptureDC == 0: - raise WindowsError("gdi:CreateCompatibleDC failed") - - # Create bitmap compatible with monitor - self._gdi32.CreateCompatibleBitmap.restype = ctypes.c_void_p - self._gdi32.CreateCompatibleBitmap.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_int] - hCaptureBmp = self._gdi32.CreateCompatibleBitmap(hdc, screen_width, screen_height) - if hCaptureBmp == 0: - raise WindowsError("gdi:CreateCompatibleBitmap failed") - - # Select hCaptureBmp into hCaptureDC device context - self._gdi32.SelectObject.argtypes = [ctypes.c_void_p, ctypes.c_void_p] - self._gdi32.SelectObject(hCaptureDC, hCaptureBmp) - - # Perform bit-block transfer from screen to device context (and thereby hCaptureBmp) - self._gdi32.BitBlt.argtypes = [ - ctypes.c_void_p, - ctypes.c_int, - ctypes.c_int, - ctypes.c_int, - ctypes.c_int, - ctypes.c_void_p, - ctypes.c_int, - ctypes.c_int, - ctypes.c_ulong - ] - self._gdi32.BitBlt(hCaptureDC, 0, 0, screen_width, screen_height, hdc, 0, 0, SRCCOPY | CAPTUREBLT) - - # Capture image bits from bitmap - img_info = BITMAPINFO() - img_info.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER) - img_info.bmiHeader.biWidth = screen_width - img_info.bmiHeader.biHeight = screen_height - img_info.bmiHeader.biPlanes = 1 - img_info.bmiHeader.biBitCount = 32 - img_info.bmiHeader.biCompression = 0 - img_info.bmiHeader.biClrUsed = 0 - img_info.bmiHeader.biClrImportant = 0 - - buffer_length = screen_width * 4 * screen_height - image_data = ctypes.create_string_buffer(buffer_length) - - self._gdi32.GetDIBits.restype = ctypes.c_int - self._gdi32.GetDIBits.argtypes = [ - ctypes.c_void_p, - ctypes.c_void_p, - ctypes.c_uint, - ctypes.c_uint, - ctypes.c_void_p, - ctypes.c_void_p, - ctypes.c_uint - ] - scanlines = self._gdi32.GetDIBits( - hCaptureDC, - hCaptureBmp, - 0, - screen_height, - ctypes.byref(image_data), - ctypes.byref(img_info), - DIB_RGB_COLORS) - if scanlines != screen_height: - raise WindowsError("gdi:GetDIBits failed") - final_image = ImageOps.flip( - Image.frombuffer( - "RGBX", - (screen_width, screen_height), - image_data, - "raw", - "RGBX", - 0, - 1)) - # Destroy created device context & GDI bitmap - self._gdi32.DeleteObject.argtypes = [ctypes.c_void_p] - self._gdi32.DeleteObject(hdc) - self._gdi32.DeleteObject(hCaptureDC) - self._gdi32.DeleteObject(hCaptureBmp) - return final_image - def _getMonitorInfo(self): - """ Returns info about the attached monitors, in device order - - [0] is always the primary monitor - """ - monitors = [] - CCHDEVICENAME = 32 - def _MonitorEnumProcCallback(hMonitor, hdcMonitor, lprcMonitor, dwData): - class MONITORINFOEX(ctypes.Structure): - _fields_ = [("cbSize", ctypes.wintypes.DWORD), - ("rcMonitor", ctypes.wintypes.RECT), - ("rcWork", ctypes.wintypes.RECT), - ("dwFlags", ctypes.wintypes.DWORD), - ("szDevice", ctypes.wintypes.WCHAR*CCHDEVICENAME)] - lpmi = MONITORINFOEX() - lpmi.cbSize = ctypes.sizeof(MONITORINFOEX) - self._user32.GetMonitorInfoW(hMonitor, ctypes.byref(lpmi)) - #hdc = self._gdi32.CreateDCA(ctypes.c_char_p(lpmi.szDevice), 0, 0, 0) - monitors.append({ - "hmon": hMonitor, - #"hdc": hdc, - "rect": (lprcMonitor.contents.left, - lprcMonitor.contents.top, - lprcMonitor.contents.right, - lprcMonitor.contents.bottom), - "name": lpmi.szDevice - }) - return True - MonitorEnumProc = ctypes.WINFUNCTYPE( - ctypes.c_bool, - ctypes.c_ulong, - ctypes.c_ulong, - ctypes.POINTER(ctypes.wintypes.RECT), - ctypes.c_int) - callback = MonitorEnumProc(_MonitorEnumProcCallback) - if self._user32.EnumDisplayMonitors(0, 0, callback, 0) == 0: - raise WindowsError("Unable to enumerate monitors") - # Clever magic to make the screen with origin of (0,0) [the primary monitor] - # the first in the list - # Sort by device ID - 0 is primary, 1 is next, etc. - monitors.sort(key=lambda x: (not (x["rect"][0] == 0 and x["rect"][1] == 0), x["name"])) - - return monitors - def _getVirtualScreenRect(self): - """ The virtual screen is the bounding box containing all monitors. - - Not all regions in the virtual screen are actually visible. The (0,0) coordinate - is the top left corner of the primary screen rather than the whole bounding box, so - some regions of the virtual screen may have negative coordinates if another screen - is positioned in Windows as further to the left or above the primary screen. - - Returns the rect as (x, y, w, h) - """ - SM_XVIRTUALSCREEN = 76 # Left of virtual screen - SM_YVIRTUALSCREEN = 77 # Top of virtual screen - SM_CXVIRTUALSCREEN = 78 # Width of virtual screen - SM_CYVIRTUALSCREEN = 79 # Heigiht of virtual screen - - return (self._user32.GetSystemMetrics(SM_XVIRTUALSCREEN), \ - self._user32.GetSystemMetrics(SM_YVIRTUALSCREEN), \ - self._user32.GetSystemMetrics(SM_CXVIRTUALSCREEN), \ - self._user32.GetSystemMetrics(SM_CYVIRTUALSCREEN)) - def _getVirtualScreenBitmap(self): - """ Returns a PIL bitmap (BGR channel order) of all monitors - - Arranged like the Virtual Screen - """ - - # Collect information about the virtual screen & monitors - min_x, min_y, screen_width, screen_height = self._getVirtualScreenRect() - monitors = self._getMonitorInfo() - - # Initialize new black image the size of the virtual screen - virt_screen = Image.new("RGB", (screen_width, screen_height)) - - # Capture images of each of the monitors and overlay on the virtual screen - for monitor_id in range(0, len(monitors)): - img = self._captureScreen(monitors[monitor_id]["name"]) - # Capture virtscreen coordinates of monitor - x1, y1, x2, y2 = monitors[monitor_id]["rect"] - # Convert to image-local coordinates - x = x1 - min_x - y = y1 - min_y - # Paste on the virtual screen - virt_screen.paste(img, (x, y)) - return virt_screen + for screen in self.getScreenDetails(): + s_x, s_y, s_w, s_h = screen["rect"] + if (s_x <= x < (s_x + s_w)) and (s_y <= y < (s_y + s_h)): + return True + return False ## Clipboard functions diff --git a/lackey/PlatformManagerWindows.py b/lackey/PlatformManagerWindows.py index f844f41..5896b9f 100644 --- a/lackey/PlatformManagerWindows.py +++ b/lackey/PlatformManagerWindows.py @@ -231,8 +231,7 @@ def getScreenBounds(self, screenId): raise ValueError("Invalid screen ID") if screenId == -1: # -1 represents the entire virtual screen - x1, y1, x2, y2 = self._getVirtualScreenRect() - return (x1, y1, x2-x1, y2-y1) + return self._getVirtualScreenRect() return screen_details[screenId]["rect"] def getScreenDetails(self): """ Return list of attached monitors @@ -452,7 +451,7 @@ def _getVirtualScreenRect(self): SM_XVIRTUALSCREEN = 76 # Left of virtual screen SM_YVIRTUALSCREEN = 77 # Top of virtual screen SM_CXVIRTUALSCREEN = 78 # Width of virtual screen - SM_CYVIRTUALSCREEN = 79 # Heigiht of virtual screen + SM_CYVIRTUALSCREEN = 79 # Height of virtual screen return (self._user32.GetSystemMetrics(SM_XVIRTUALSCREEN), \ self._user32.GetSystemMetrics(SM_YVIRTUALSCREEN), \ From 48d687a0826920bf23d828a3d6ce8870615a48ff Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Mon, 1 May 2017 21:08:45 -0400 Subject: [PATCH 03/37] Completed initial mockup --- lackey/App.py | 7 +- ...ManagerOSX.py => PlatformManagerDarwin.py} | 133 ++++-------------- lackey/RegionMatching.py | 8 +- lackey/__init__.py | 4 +- 4 files changed, 41 insertions(+), 111 deletions(-) rename lackey/{PlatformManagerOSX.py => PlatformManagerDarwin.py} (70%) diff --git a/lackey/App.py b/lackey/App.py index 85110df..9e3a0e0 100644 --- a/lackey/App.py +++ b/lackey/App.py @@ -8,15 +8,18 @@ from .RegionMatching import Region from .Settings import Debug -from .PlatformManagerWindows import PlatformManagerWindows from .Exceptions import FindFailed if platform.system() == "Windows": + from .PlatformManagerWindows import PlatformManagerWindows PlatformManager = PlatformManagerWindows() # No other input managers built yet +elif platform.system() == "Darwin": + from .PlatformManagerDarwin import PlatformManagerDarwin + PlatformManager = PlatformManagerDarwin() else: # Avoid throwing an error if it's just being imported for documentation purposes if not os.environ.get('READTHEDOCS') == 'True': - raise NotImplementedError("Lackey is currently only compatible with Windows.") + raise NotImplementedError("Lackey is currently only compatible with Windows and OSX.") # Python 3 compatibility try: diff --git a/lackey/PlatformManagerOSX.py b/lackey/PlatformManagerDarwin.py similarity index 70% rename from lackey/PlatformManagerOSX.py rename to lackey/PlatformManagerDarwin.py index 591360a..3dc52f7 100644 --- a/lackey/PlatformManagerOSX.py +++ b/lackey/PlatformManagerDarwin.py @@ -1,15 +1,17 @@ -""" Platform-specific code for Windows is encapsulated in this module. """ +""" Platform-specific code for Darwin is encapsulated in this module. """ import os import re import time import numpy -import ctypes +import AppKit +import tempfile +import subprocess try: import Tkinter as tk except ImportError: import tkinter as tk -from ctypes import wintypes + from PIL import Image, ImageTk, ImageOps from .Settings import Debug @@ -21,8 +23,8 @@ except NameError: basestring = str -class PlatformManagerOSX(object): - """ Abstracts OSX-specific OS-level features """ +class PlatformManagerDarwin(object): + """ Abstracts Darwin-specific OS-level features """ def __init__(self): # Mapping to `keyboard` names @@ -228,7 +230,7 @@ def _getVirtualScreenBitmap(self): fh, filepath = tempfile.mkstemp('.png') filenames.append(filepath) os.close(fh) - subprocess.call(['screencapture', '-x', *filenames]) + subprocess.call(['screencapture', '-x'] + filenames) min_x, min_y, screen_w, screen_h = self._getVirtualScreenRect() virtual_screen = Image.new("RGB", (screen_w, screen_h)) @@ -243,7 +245,6 @@ def _getVirtualScreenBitmap(self): # Paste on the virtual screen virtual_screen.paste(im, (x, y)) os.unlink(filename) - def getScreenDetails(self): """ Return list of attached monitors @@ -293,76 +294,33 @@ def osPaste(self): def getWindowByTitle(self, wildcard, order=0): """ Returns a handle for the first window that matches the provided "wildcard" regex """ - EnumWindowsProc = ctypes.WINFUNCTYPE( - ctypes.c_bool, - ctypes.POINTER(ctypes.c_int), - ctypes.py_object) - def callback(hwnd, context): - if ctypes.windll.user32.IsWindowVisible(hwnd): - length = ctypes.windll.user32.GetWindowTextLengthW(hwnd) - buff = ctypes.create_unicode_buffer(length + 1) - ctypes.windll.user32.GetWindowTextW(hwnd, buff, length + 1) - if re.search(context["wildcard"], buff.value) != None and not context["handle"]: - if context["order"] > 0: - context["order"] -= 1 - else: - context["handle"] = hwnd - return True - data = {"wildcard": wildcard, "handle": None, "order": order} - ctypes.windll.user32.EnumWindows(EnumWindowsProc(callback), ctypes.py_object(data)) - return data["handle"] + # TODO + pass def getWindowByPID(self, pid, order=0): """ Returns a handle for the first window that matches the provided PID """ - if pid <= 0: - return None - EnumWindowsProc = ctypes.WINFUNCTYPE( - ctypes.c_bool, - ctypes.POINTER(ctypes.c_int), - ctypes.py_object) - def callback(hwnd, context): - if ctypes.windll.user32.IsWindowVisible(hwnd): - pid = ctypes.c_long() - ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) - if context["pid"] == int(pid.value) and not context["handle"]: - if context["order"] > 0: - context["order"] -= 1 - else: - context["handle"] = hwnd - return True - data = {"pid": pid, "handle": None, "order": order} - ctypes.windll.user32.EnumWindows(EnumWindowsProc(callback), ctypes.py_object(data)) - return data["handle"] + # TODO + pass def getWindowRect(self, hwnd): """ Returns a rect (x,y,w,h) for the specified window's area """ - rect = ctypes.wintypes.RECT() - if ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect)): - x1 = rect.left - y1 = rect.top - x2 = rect.right - y2 = rect.bottom - return (x1, y1, x2-x1, y2-y1) - return None + # TODO + pass def focusWindow(self, hwnd): """ Brings specified window to the front """ Debug.log(3, "Focusing window: " + str(hwnd)) - SW_RESTORE = 9 - if ctypes.windll.user32.IsIconic(hwnd): - ctypes.windll.user32.ShowWindow(hwnd, SW_RESTORE) - ctypes.windll.user32.SetForegroundWindow(hwnd) + # TODO + pass def getWindowTitle(self, hwnd): """ Gets the title for the specified window """ - length = ctypes.windll.user32.GetWindowTextLengthW(hwnd) - buff = ctypes.create_unicode_buffer(length + 1) - ctypes.windll.user32.GetWindowTextW(hwnd, buff, length + 1) - return buff.value + # TODO + pass def getWindowPID(self, hwnd): """ Gets the process ID that the specified window belongs to """ - pid = ctypes.c_long() - ctypes.windll.user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) - return int(pid.value) + # TODO + pass def getForegroundWindow(self): """ Returns a handle to the window in the foreground """ - return self._user32.GetForegroundWindow() + # TODO + pass ## Highlighting functions @@ -397,50 +355,15 @@ def highlight(self, rect, seconds=1): ## Process functions def isPIDValid(self, pid): """ Checks if a PID is associated with a running process """ - ## Slightly copied wholesale from http://stackoverflow.com/questions/568271/how-to-check-if-there-exists-a-process-with-a-given-pid - ## Thanks to http://stackoverflow.com/users/1777162/ntrrgc and http://stackoverflow.com/users/234270/speedplane - class ExitCodeProcess(ctypes.Structure): - _fields_ = [('hProcess', ctypes.c_void_p), - ('lpExitCode', ctypes.POINTER(ctypes.c_ulong))] - SYNCHRONIZE = 0x100000 - PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 - process = self._kernel32.OpenProcess(SYNCHRONIZE|PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) - if not process: - return False - ec = ExitCodeProcess() - out = self._kernel32.GetExitCodeProcess(process, ctypes.byref(ec)) - if not out: - err = self._kernel32.GetLastError() - if self._kernel32.GetLastError() == 5: - # Access is denied. - logging.warning("Access is denied to get pid info.") - self._kernel32.CloseHandle(process) - return False - elif bool(ec.lpExitCode): - # There is an exit code, it quit - self._kernel32.CloseHandle(process) - return False - # No exit code, it's running. - self._kernel32.CloseHandle(process) - return True + # TODO + pass def killProcess(self, pid): """ Kills the process with the specified PID (if possible) """ - SYNCHRONIZE = 0x00100000 - PROCESS_TERMINATE = 0x0001 - hProcess = self._kernel32.OpenProcess(SYNCHRONIZE|PROCESS_TERMINATE, True, pid) - result = self._kernel32.TerminateProcess(hProcess, 0) - self._kernel32.CloseHandle(hProcess) + # TODO + pass def getProcessName(self, pid): - if pid <= 0: - return "" - MAX_PATH_LEN = 2048 - proc_name = ctypes.create_string_buffer(MAX_PATH_LEN) - PROCESS_VM_READ = 0x0010 - PROCESS_QUERY_INFORMATION = 0x0400 - hProcess = self._kernel32.OpenProcess(PROCESS_VM_READ|PROCESS_QUERY_INFORMATION, 0, pid) - #self._psapi.GetProcessImageFileName.restype = ctypes.wintypes.DWORD - self._psapi.GetModuleFileNameExA(hProcess, 0, ctypes.byref(proc_name), MAX_PATH_LEN) - return os.path.basename(proc_name.value.decode("utf-8")) + # TODO + pass ## Helper class for highlighting diff --git a/lackey/RegionMatching.py b/lackey/RegionMatching.py index 24f6d1d..c77bbc8 100644 --- a/lackey/RegionMatching.py +++ b/lackey/RegionMatching.py @@ -17,7 +17,7 @@ import re -from .PlatformManagerWindows import PlatformManagerWindows + from .InputEmulation import Mouse, Keyboard from .Location import Location from .Exceptions import FindFailed @@ -25,11 +25,15 @@ from .TemplateMatchers import PyramidTemplateMatcher as TemplateMatcher if platform.system() == "Windows": + from .PlatformManagerWindows import PlatformManagerWindows PlatformManager = PlatformManagerWindows() # No other input managers built yet +elif platform.system() == "Darwin": + from .PlatformManagerDarwin import PlatformManagerDarwin + PlatformManager = PlatformManagerDarwin() else: # Avoid throwing an error if it's just being imported for documentation purposes if not os.environ.get('READTHEDOCS') == 'True': - raise NotImplementedError("Lackey is currently only compatible with Windows.") + raise NotImplementedError("Lackey is currently only compatible with Windows and OSX.") # Python 3 compatibility try: diff --git a/lackey/__init__.py b/lackey/__init__.py index fc0b8c0..da9801d 100644 --- a/lackey/__init__.py +++ b/lackey/__init__.py @@ -26,7 +26,7 @@ ## Lackey sub-files -from .PlatformManagerWindows import PlatformManagerWindows +#from .PlatformManagerWindows import PlatformManagerWindows from .KeyCodes import Button, Key, KeyModifier from .RegionMatching import Pattern, Region, Match, Screen, ObserveEvent from .Location import Location @@ -37,7 +37,7 @@ from .SikuliGui import PopupInput, PopupList, PopupTextarea from ._version import __version__ -VALID_PLATFORMS = ["Windows"] +VALID_PLATFORMS = ["Windows", "Darwin"] ## Define script abort hotkey (Alt+Shift+C) From a381fcefc59db08bbe2970b8b042346433ae1cb4 Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Wed, 10 May 2017 14:59:43 -0400 Subject: [PATCH 04/37] Updating unit tests for OS X --- lackey/PlatformManagerDarwin.py | 96 +++++++++++++++++++--------- lackey/RegionMatching.py | 10 +-- lackey/__init__.py | 2 +- tests/appveyor_test_cases.py | 49 ++++++++------- tests/test_cases.py | 108 +++++++++++++++++++++----------- 5 files changed, 169 insertions(+), 96 deletions(-) diff --git a/lackey/PlatformManagerDarwin.py b/lackey/PlatformManagerDarwin.py index 3dc52f7..b8192c6 100644 --- a/lackey/PlatformManagerDarwin.py +++ b/lackey/PlatformManagerDarwin.py @@ -3,16 +3,17 @@ import os import re import time -import numpy -import AppKit import tempfile import subprocess try: import Tkinter as tk except ImportError: import tkinter as tk - -from PIL import Image, ImageTk, ImageOps + +import numpy +import AppKit +import Quartz +from PIL import Image, ImageTk from .Settings import Debug from .InputEmulation import Keyboard @@ -219,8 +220,8 @@ def _getVirtualScreenRect(self): monitors = self.getScreenDetails() x1 = min([s["rect"][0] for s in monitors]) y1 = min([s["rect"][1] for s in monitors]) - x2 = max([s["rect"][0]+s["rect"][3] for s in monitors]) - y2 = max([s["rect"][1]+s["rect"][4] for s in monitors]) + x2 = max([s["rect"][0]+s["rect"][2] for s in monitors]) + y2 = max([s["rect"][1]+s["rect"][3] for s in monitors]) return (x1, y1, x2-x1, y2-y1) def _getVirtualScreenBitmap(self): """ Returns a bitmap of all attached screens """ @@ -245,6 +246,7 @@ def _getVirtualScreenBitmap(self): # Paste on the virtual screen virtual_screen.paste(im, (x, y)) os.unlink(filename) + return virtual_screen def getScreenDetails(self): """ Return list of attached monitors @@ -259,10 +261,10 @@ def getScreenDetails(self): # Convert screen rect to Lackey-style rect (x,y,w,h) as position in virtual screen screen = { "rect": ( - monitor.frame().origin.x, - monitor.frame().origin.y, - monitor.frame().size.width, - monitor.frame().size.height + int(monitor.frame().origin.x), + int(monitor.frame().origin.y), + int(monitor.frame().size.width), + int(monitor.frame().size.height) ) } screens.append(screen) @@ -294,16 +296,34 @@ def osPaste(self): def getWindowByTitle(self, wildcard, order=0): """ Returns a handle for the first window that matches the provided "wildcard" regex """ - # TODO - pass + for w in self._get_window_list(): + if "kCGWindowName" in w and re.search(wildcard, w["kCGWindowName"], flags=re.I): + # Matches - make sure we get it in the correct order + if order == 0: + return w["kCGWindowNumber"] + else: + order -= 1 + def getWindowByPID(self, pid, order=0): """ Returns a handle for the first window that matches the provided PID """ - # TODO - pass + for w in self._get_window_list(): + if "kCGWindowOwnerPID" in w and w["kCGWindowOwnerPID"] == pid: + # Matches - make sure we get it in the correct order + if order == 0: + return w["kCGWindowNumber"] + else: + order -= 1 + raise OSError("Could not find window for PID {} at index {}".format(pid, order)) def getWindowRect(self, hwnd): """ Returns a rect (x,y,w,h) for the specified window's area """ - # TODO - pass + for w in self._get_window_list(): + if "kCGWindowNumber" in w and w["kCGWindowNumber"] == hwnd: + x = w["kCGWindowBounds"]["X"] + y = w["kCGWindowBounds"]["Y"] + width = w["kCGWindowBounds"]["Width"] + height = w["kCGWindowBounds"]["Height"] + return (x, y, width, height) + raise OSError("Unrecognized window number {}".format(hwnd)) def focusWindow(self, hwnd): """ Brings specified window to the front """ Debug.log(3, "Focusing window: " + str(hwnd)) @@ -311,16 +331,25 @@ def focusWindow(self, hwnd): pass def getWindowTitle(self, hwnd): """ Gets the title for the specified window """ - # TODO - pass + for w in self._get_window_list(): + if "kCGWindowNumber" in w and w["kCGWindowNumber"] == hwnd: + return w["kCGWindowName"] def getWindowPID(self, hwnd): """ Gets the process ID that the specified window belongs to """ - # TODO - pass + for w in self._get_window_list(): + if "kCGWindowNumber" in w and w["kCGWindowNumber"] == hwnd: + return w["kCGWindowOwnerPID"] def getForegroundWindow(self): """ Returns a handle to the window in the foreground """ - # TODO - pass + active_app = NSWorkspace.sharedWorkspace().frontmostApplication().localizedName() + for w in self._get_window_list(): + if "kCGWindowOwnerName" in w and w["kCGWindowOwnerName"] == active_app: + return w["kCGWindowNumber"] + + def _get_window_list(self): + """ Returns a dictionary of details about open windows """ + window_list = Quartz.CGWindowListCopyWindowInfo(Quartz.kCGWindowListExcludeDesktopElements, Quartz.kCGNullWindowID) + return window_list ## Highlighting functions @@ -355,15 +384,26 @@ def highlight(self, rect, seconds=1): ## Process functions def isPIDValid(self, pid): """ Checks if a PID is associated with a running process """ - # TODO - pass + try: + os.kill(pid, 0) # Does nothing if valid, raises exception otherwise + except OSError: + return False + else: + return True def killProcess(self, pid): """ Kills the process with the specified PID (if possible) """ - # TODO - pass + os.kill(pid, 15) def getProcessName(self, pid): - # TODO - pass + """ Searches all processes for the given PID, then returns the originating command """ + ps = subprocess.check_output(["ps", "aux"]).decode("ascii") + processes = ps.split("\n") + cols = len(processes[0].split()) - 1 + for row in processes[1:]: + if row != "": + proc = row.split(None, cols) + if proc[1].strip() == str(pid): + print(row) + return proc[-1] ## Helper class for highlighting diff --git a/lackey/RegionMatching.py b/lackey/RegionMatching.py index 1e29b05..65be68b 100644 --- a/lackey/RegionMatching.py +++ b/lackey/RegionMatching.py @@ -94,10 +94,8 @@ def getTargetOffset(self): def debugPreview(self, title="Debug"): """ Loads and displays the image at ``Pattern.path`` """ - haystack = cv2.imread(self.path) - cv2.imshow(title, haystack) - cv2.waitKey(0) - cv2.destroyAllWindows() + haystack = Image.open(self.path) + haystack.show() class Region(object): def __init__(self, *args): @@ -385,9 +383,7 @@ def debugPreview(self, title="Debug"): if haystack.shape[0] > (Screen(0).getBounds()[2]/2) or haystack.shape[1] > (Screen(0).getBounds()[3]/2): # Image is bigger than half the screen; scale it down haystack = cv2.resize(haystack, (0, 0), fx=0.5, fy=0.5) - cv2.imshow(title, haystack) - cv2.waitKey(0) - cv2.destroyAllWindows() + Image.fromarray(haystack).show() def highlight(self, seconds=1): """ Temporarily using ``debugPreview()`` to show the region instead of highlighting it diff --git a/lackey/__init__.py b/lackey/__init__.py index da9801d..8775383 100644 --- a/lackey/__init__.py +++ b/lackey/__init__.py @@ -28,7 +28,7 @@ #from .PlatformManagerWindows import PlatformManagerWindows from .KeyCodes import Button, Key, KeyModifier -from .RegionMatching import Pattern, Region, Match, Screen, ObserveEvent +from .RegionMatching import Pattern, Region, Match, Screen, ObserveEvent, PlatformManager from .Location import Location from .InputEmulation import Mouse, Keyboard from .App import App diff --git a/tests/appveyor_test_cases.py b/tests/appveyor_test_cases.py index bd1f349..276fe88 100644 --- a/tests/appveyor_test_cases.py +++ b/tests/appveyor_test_cases.py @@ -38,28 +38,29 @@ def test_offsets(self): class TestPatternMethods(unittest.TestCase): def setUp(self): - self.pattern = lackey.Pattern("tests\\test_pattern.png") + self.file_path = os.path.join("tests", "test_pattern.png") + self.pattern = lackey.Pattern(self.file_path) def test_defaults(self): self.assertEqual(self.pattern.similarity, 0.7) self.assertIsInstance(self.pattern.offset, lackey.Location) self.assertEqual(self.pattern.offset.getTuple(), (0,0)) - self.assertEqual(self.pattern.path[-len("tests\\test_pattern.png"):], "tests\\test_pattern.png") + self.assertEqual(self.pattern.path[-len(self.file_path):], self.file_path) def test_setters(self): test_pattern = self.pattern.similar(0.5) self.assertEqual(test_pattern.similarity, 0.5) - self.assertEqual(test_pattern.path[-len("tests\\test_pattern.png"):], "tests\\test_pattern.png") + self.assertEqual(test_pattern.path[-len(self.file_path):], self.file_path) test_pattern = self.pattern.exact() self.assertEqual(test_pattern.similarity, 1.0) - self.assertEqual(test_pattern.path[-len("tests\\test_pattern.png"):], "tests\\test_pattern.png") + self.assertEqual(test_pattern.path[-len(self.file_path):], self.file_path) test_pattern = self.pattern.targetOffset(3, 5) self.assertEqual(test_pattern.similarity, 0.7) - self.assertEqual(test_pattern.path[-len("tests\\test_pattern.png"):], "tests\\test_pattern.png") + self.assertEqual(test_pattern.path[-len(self.file_path):], self.file_path) self.assertEqual(test_pattern.offset.getTuple(), (3,5)) def test_getters(self): - self.assertEqual(self.pattern.getFilename()[-len("tests\\test_pattern.png"):], "tests\\test_pattern.png") + self.assertEqual(self.pattern.getFilename()[-len(self.file_path):], self.file_path) self.assertEqual(self.pattern.getTargetOffset().getTuple(), (0,0)) class TestObserverEventMethods(unittest.TestCase): def setUp(self): @@ -225,7 +226,7 @@ def test_match_interface(self): self.assertHasMethod(lackey.Match, "getTarget", 1) def test_location_interface(self): - """ Checking Match class interface methods """ + """ Checking Location class interface methods """ self.assertHasMethod(lackey.Location, "__init__", 3) self.assertHasMethod(lackey.Location, "getX", 1) self.assertHasMethod(lackey.Location, "getY", 1) @@ -237,7 +238,7 @@ def test_location_interface(self): self.assertHasMethod(lackey.Location, "right", 2) def test_screen_interface(self): - """ Checking Match class interface methods """ + """ Checking Screen class interface methods """ self.assertHasMethod(lackey.Screen, "__init__", 2) self.assertHasMethod(lackey.Screen, "getNumberScreens", 1) self.assertHasMethod(lackey.Screen, "getBounds", 1) @@ -248,28 +249,28 @@ def test_platform_manager_interface(self): """ Checking Platform Manager interface methods """ ## Screen methods - self.assertHasMethod(lackey.PlatformManagerWindows, "getBitmapFromRect", 5) - self.assertHasMethod(lackey.PlatformManagerWindows, "getScreenBounds", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "getScreenDetails", 1) - self.assertHasMethod(lackey.PlatformManagerWindows, "isPointVisible", 3) + self.assertHasMethod(lackey.PlatformManager, "getBitmapFromRect", 5) + self.assertHasMethod(lackey.PlatformManager, "getScreenBounds", 2) + self.assertHasMethod(lackey.PlatformManager, "getScreenDetails", 1) + self.assertHasMethod(lackey.PlatformManager, "isPointVisible", 3) ## Clipboard methods - self.assertHasMethod(lackey.PlatformManagerWindows, "osCopy", 1) - self.assertHasMethod(lackey.PlatformManagerWindows, "osPaste", 1) + self.assertHasMethod(lackey.PlatformManager, "osCopy", 1) + self.assertHasMethod(lackey.PlatformManager, "osPaste", 1) ## Window methods - self.assertHasMethod(lackey.PlatformManagerWindows, "getWindowByTitle", 3) - self.assertHasMethod(lackey.PlatformManagerWindows, "getWindowByPID", 3) - self.assertHasMethod(lackey.PlatformManagerWindows, "getWindowRect", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "focusWindow", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "getWindowTitle", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "getWindowPID", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "getForegroundWindow", 1) + self.assertHasMethod(lackey.PlatformManager, "getWindowByTitle", 3) + self.assertHasMethod(lackey.PlatformManager, "getWindowByPID", 3) + self.assertHasMethod(lackey.PlatformManager, "getWindowRect", 2) + self.assertHasMethod(lackey.PlatformManager, "focusWindow", 2) + self.assertHasMethod(lackey.PlatformManager, "getWindowTitle", 2) + self.assertHasMethod(lackey.PlatformManager, "getWindowPID", 2) + self.assertHasMethod(lackey.PlatformManager, "getForegroundWindow", 1) ## Process methods - self.assertHasMethod(lackey.PlatformManagerWindows, "isPIDValid", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "killProcess", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "getProcessName", 2) + self.assertHasMethod(lackey.PlatformManager, "isPIDValid", 2) + self.assertHasMethod(lackey.PlatformManager, "killProcess", 2) + self.assertHasMethod(lackey.PlatformManager, "getProcessName", 2) def assertHasMethod(self, cls, mthd, args=0): diff --git a/tests/test_cases.py b/tests/test_cases.py index 32a39d6..3433a42 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -57,52 +57,88 @@ def test_getters(self): app2.open() lackey.sleep(1) app2.close() + app.focus() + self.assertEqual(app.getName(), "(open)") + self.assertTrue(app.isRunning()) + self.assertEqual(app.getWindow(), "test_cases.py - Notepad") + self.assertNotEqual(app.getPID(), -1) + region = app.window() + self.assertIsInstance(region, lackey.Region) + self.assertGreater(region.getW(), 0) + self.assertGreater(region.getH(), 0) + app.close() + elif sys.platform == "darwin": + a = lackey.App("+open -a TextEdit tests/test_cases.py") + a2 = lackey.App("open -a TextEdit tests/appveyor_test_cases.py") + lackey.sleep(1) + app = lackey.App("test_cases.py") + app2 = lackey.App("appveyor_test_cases.py") + #app.setUsing("test_cases.py") + lackey.sleep(1) + app2.close() + app.focus() + print(app.getPID()) + self.assertEqual(app.getName()[-len("TextEdit"):], "TextEdit") + self.assertTrue(app.isRunning()) + #self.assertEqual(app.getWindow(), "test_cases.py") # Doesn't work on `open`-triggered apps + self.assertNotEqual(app.getPID(), -1) + region = app.window() + self.assertIsInstance(region, lackey.Region) + self.assertGreater(region.getW(), 0) + self.assertGreater(region.getH(), 0) + app.close() else: - raise NotImplementedError("Platforms supported include: Windows") - app.focus() - - self.assertEqual(app.getName(), "notepad.exe") - self.assertTrue(app.isRunning()) - self.assertEqual(app.getWindow(), "test_cases.py - Notepad") - self.assertNotEqual(app.getPID(), -1) - region = app.window() - self.assertIsInstance(region, lackey.Region) - self.assertGreater(region.getW(), 0) - self.assertGreater(region.getH(), 0) + raise NotImplementedError("Platforms supported include: Windows, OS X") + def test_launchers(self): if sys.platform.startswith("win"): + app = lackey.App("notepad.exe") + app.setUsing("tests\\test_cases.py") + app.open() + lackey.wait(1) + self.assertEqual(app.getName(), "(open)") + self.assertTrue(app.isRunning()) + self.assertEqual(app.getWindow(), "test_cases.py") + self.assertNotEqual(app.getPID(), -1) app.close() - lackey.sleep(1.0) - - def test_launchers(self): - app = lackey.App("notepad.exe") - app.setUsing("tests\\test_cases.py") - app.open() - lackey.wait(1) - self.assertEqual(app.getName(), "notepad.exe") - self.assertTrue(app.isRunning()) - self.assertEqual(app.getWindow(), "test_cases.py - Notepad") - self.assertNotEqual(app.getPID(), -1) - app.close() - lackey.wait(0.9) + lackey.wait(0.9) + elif sys.platform.startswith("darwin"): + a = lackey.App("open") + a.setUsing("-a TextEdit tests/test_cases.py") + a.open() + lackey.wait(1) + app = lackey.App("test_cases.py") + self.assertEqual(app.getName()[-len("TextEdit"):], "TextEdit") + self.assertTrue(app.isRunning()) + #self.assertEqual(app.getWindow(), "test_cases.py") # Doesn't work on `open`-triggered apps + self.assertNotEqual(app.getPID(), -1) + app.close() + lackey.wait(0.9) + else: + raise NotImplementedError("Platforms supported include: Windows, OS X") def test_app_title(self): """ App selected by title should capture existing window if open, including case-insensitive matches. """ - app = lackey.App("notepad.exe") - app.open() - lackey.wait(1) - app2 = lackey.App("Notepad") - app3 = lackey.App("notepad") - lackey.wait(1) - - self.assertTrue(app2.isRunning()) - self.assertTrue(app3.isRunning()) - self.assertEqual(app2.getName(), app.getName()) - self.assertEqual(app3.getName(), app.getName()) - app.close() + if sys.platform.startswith("win"): + app = lackey.App("notepad.exe") + app.open() + lackey.wait(1) + app2 = lackey.App("Notepad") + app3 = lackey.App("notepad") + lackey.wait(1) + + self.assertTrue(app2.isRunning()) + self.assertTrue(app3.isRunning()) + self.assertEqual(app2.getName(), app.getName()) + self.assertEqual(app3.getName(), app.getName()) + app.close() + elif sys.platform.startswith("darwin"): + pass # Skip this test, due to issues with `open` not being the window owner on Mac + else: + raise NotImplementedError("Platforms supported include: Windows, OS X") class TestScreenMethods(unittest.TestCase): From 035f0500bc43f1bdaccb9277c5895f71d1c09345 Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Wed, 10 May 2017 15:19:05 -0400 Subject: [PATCH 05/37] Fixing more test cases --- lackey/Exceptions.py | 9 +++++++-- lackey/InputEmulation.py | 2 +- lackey/RegionMatching.py | 2 +- tests/preview_open.png | Bin 0 -> 17297 bytes tests/test_cases.py | 11 ++++++++--- 5 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 tests/preview_open.png diff --git a/lackey/Exceptions.py b/lackey/Exceptions.py index fff3698..6f47181 100644 --- a/lackey/Exceptions.py +++ b/lackey/Exceptions.py @@ -2,5 +2,10 @@ class FindFailed(Exception): """ Exception: Unable to find the searched item """ - def __init__(self, *args, **kwargs): - Exception.__init__(self, *args, **kwargs) + def __init__(self, event): + Exception.__init__(self, str(event)) + +class ImageMissing(Exception): + """ Exception: Unable to find the image file """ + def __init__(self, event): + Exception.__init__(self, str(event)) \ No newline at end of file diff --git a/lackey/InputEmulation.py b/lackey/InputEmulation.py index 90db49c..5a4d535 100644 --- a/lackey/InputEmulation.py +++ b/lackey/InputEmulation.py @@ -127,7 +127,7 @@ def __init__(self): "INSERT": "ins", "DELETE": "del", "WIN": "win", - "CMD": "win", + "CMD": "command", "META": "win", "NUM0": "keypad 0", "NUM1": "keypad 1", diff --git a/lackey/RegionMatching.py b/lackey/RegionMatching.py index 65be68b..1b9c70a 100644 --- a/lackey/RegionMatching.py +++ b/lackey/RegionMatching.py @@ -478,7 +478,7 @@ def wait(self, pattern, seconds=None): if time.time() >= timeout: break path = pattern.path if isinstance(pattern, Pattern) else pattern - findFailedRetry = _raiseFindFailed("Could not find pattern '{}'".format(path)) + findFailedRetry = self._raiseFindFailed("Could not find pattern '{}'".format(path)) return None def waitVanish(self, pattern, seconds=None): """ Waits until the specified pattern is not visible on screen. diff --git a/tests/preview_open.png b/tests/preview_open.png new file mode 100644 index 0000000000000000000000000000000000000000..d1f03fdb0d1f16164e07e1175bea1ffb3e36b4a5 GIT binary patch literal 17297 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 literal 0 HcmV?d00001 diff --git a/tests/test_cases.py b/tests/test_cases.py index 3433a42..1dc62c9 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -168,8 +168,14 @@ def testTypeCopyPaste(self): if sys.platform.startswith("win"): app = lackey.App("notepad.exe").open() time.sleep(1) + elif sys.platform == "darwin": + a = lackey.App("+open -a TextEdit") + lackey.wait("preview_open.png") + lackey.type("n", lackey.KeyModifier.CMD) + time.sleep(1) + app = lackey.App("Untitled") else: - raise NotImplementedError("Platforms supported include: Windows") + raise NotImplementedError("Platforms supported include: Windows, OS X") r = app.window() r.type("This is a Test") @@ -182,8 +188,7 @@ def testTypeCopyPaste(self): r.type("c", lackey.Key.CONTROL) # Copy self.assertEqual(r.getClipboard(), "This, on the other hand, is a {SHIFT}broken {SHIFT}record.") - if sys.platform.startswith("win"): - app.close() + app.close() lackey.Debug.setLogFile(None) From ff1a8a6dceffed3f7c51fdccc0e407b785e96323 Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Wed, 10 May 2017 21:01:10 -0400 Subject: [PATCH 06/37] Reconciled master merge --- lackey/App.py | 6 -- lackey/Exceptions.py | 5 -- lackey/PlatformManagerDarwin.py | 32 ++++---- lackey/RegionMatching.py | 22 +----- lackey/__init__.py | 5 -- tests/appveyor_test_cases.py | 114 +++++++++++++++++----------- tests/test_cases.py | 129 -------------------------------- 7 files changed, 89 insertions(+), 224 deletions(-) diff --git a/lackey/App.py b/lackey/App.py index 35d9865..2912ba7 100644 --- a/lackey/App.py +++ b/lackey/App.py @@ -7,13 +7,7 @@ import subprocess from .RegionMatching import Region -<<<<<<< HEAD -from .Settings import Debug -from .Exceptions import FindFailed -======= from .SettingsDebug import Debug -from .PlatformManagerWindows import PlatformManagerWindows ->>>>>>> master if platform.system() == "Windows": from .PlatformManagerWindows import PlatformManagerWindows diff --git a/lackey/Exceptions.py b/lackey/Exceptions.py index bb7d240..efd5f1a 100644 --- a/lackey/Exceptions.py +++ b/lackey/Exceptions.py @@ -8,9 +8,4 @@ def __init__(self, event): class ImageMissing(Exception): """ Exception: Unable to find the image file """ def __init__(self, event): -<<<<<<< HEAD Exception.__init__(self, str(event)) -======= - Exception.__init__(self, str(event)) - ->>>>>>> master diff --git a/lackey/PlatformManagerDarwin.py b/lackey/PlatformManagerDarwin.py index b8192c6..da972c7 100644 --- a/lackey/PlatformManagerDarwin.py +++ b/lackey/PlatformManagerDarwin.py @@ -2,8 +2,8 @@ import os import re -import time import tempfile +import threading import subprocess try: import Tkinter as tk @@ -15,7 +15,7 @@ import Quartz from PIL import Image, ImageTk -from .Settings import Debug +from .SettingsDebug import Debug from .InputEmulation import Keyboard # Python 3 compatibility @@ -353,7 +353,7 @@ def _get_window_list(self): ## Highlighting functions - def highlight(self, rect, seconds=1): + def highlight(self, rect, color="red", seconds=None): """ Simulates a transparent rectangle over the specified ``rect`` on the screen. Actually takes a screenshot of the region and displays with a @@ -372,14 +372,12 @@ def highlight(self, rect, seconds=1): temporary_root = False root = tk._default_root image_to_show = self.getBitmapFromRect(*rect) - app = highlightWindow(root, rect, image_to_show) - timeout = time.time()+seconds - while time.time() < timeout: - app.update_idletasks() - app.update() - app.destroy() - if temporary_root: - root.destroy() + app = highlightWindow(root, rect, color, image_to_show) + if seconds == 0: + t = threading.Thread(target=app.do_until_timeout) + t.start() + return app + app.do_until_timeout(seconds) ## Process functions def isPIDValid(self, pid): @@ -402,13 +400,12 @@ def getProcessName(self, pid): if row != "": proc = row.split(None, cols) if proc[1].strip() == str(pid): - print(row) return proc[-1] ## Helper class for highlighting class highlightWindow(tk.Toplevel): - def __init__(self, root, rect, screen_cap): + def __init__(self, root, rect, frame_color, screen_cap): """ Accepts rect as (x,y,w,h) """ self.root = root tk.Toplevel.__init__(self, self.root, bg="red", bd=0) @@ -433,10 +430,17 @@ def __init__(self, root, rect, screen_cap): 2, rect[2]-2, rect[3]-2, - outline="red", + outline=frame_color, width=4) self.canvas.pack(fill=tk.BOTH, expand=tk.YES) ## Lift to front if necessary and refresh. self.lift() self.update() + def do_until_timeout(self, seconds=None): + if seconds is not None: + self.root.after(seconds*1000, self.root.destroy) + self.root.mainloop() + + def close(self): + self.root.destroy() diff --git a/lackey/RegionMatching.py b/lackey/RegionMatching.py index 5b2992e..3adca44 100644 --- a/lackey/RegionMatching.py +++ b/lackey/RegionMatching.py @@ -19,19 +19,9 @@ import os import re - -<<<<<<< HEAD - -from .InputEmulation import Mouse, Keyboard -from .Location import Location -from .Exceptions import FindFailed -from .Settings import Settings, Debug -======= -from .PlatformManagerWindows import PlatformManagerWindows from .InputEmulation import Mouse as MouseClass, Keyboard from .Exceptions import FindFailed, ImageMissing from .SettingsDebug import Settings, Debug ->>>>>>> master from .TemplateMatchers import PyramidTemplateMatcher as TemplateMatcher from .Geometry import Location @@ -474,17 +464,10 @@ def debugPreview(self, title="Debug"): if haystack.shape[0] > (Screen(0).getBounds()[2]/2) or haystack.shape[1] > (Screen(0).getBounds()[3]/2): # Image is bigger than half the screen; scale it down haystack = cv2.resize(haystack, (0, 0), fx=0.5, fy=0.5) -<<<<<<< HEAD Image.fromarray(haystack).show() - def highlight(self, seconds=1): - """ Temporarily using ``debugPreview()`` to show the region instead of highlighting it -======= - cv2.imshow(title, haystack) - cv2.waitKey(0) - cv2.destroyAllWindows() + def highlight(self, *args): """ Highlights the region with a colored frame. Accepts the following parameters: ->>>>>>> master highlight([toEnable], [seconds], [color]) @@ -600,11 +583,8 @@ def wait(self, pattern, seconds=None): break path = pattern.path if isinstance(pattern, Pattern) else pattern findFailedRetry = self._raiseFindFailed("Could not find pattern '{}'".format(path)) -<<<<<<< HEAD -======= if findFailedRetry: time.sleep(self._repeatWaitTime) ->>>>>>> master return None def waitVanish(self, pattern, seconds=None): """ Waits until the specified pattern is not visible on screen. diff --git a/lackey/__init__.py b/lackey/__init__.py index 01af93f..96ffe8b 100644 --- a/lackey/__init__.py +++ b/lackey/__init__.py @@ -28,13 +28,8 @@ #from .PlatformManagerWindows import PlatformManagerWindows from .KeyCodes import Button, Key, KeyModifier -<<<<<<< HEAD from .RegionMatching import Pattern, Region, Match, Screen, ObserveEvent, PlatformManager -from .Location import Location -======= -from .RegionMatching import Pattern, Region, Match, Screen, ObserveEvent from .Geometry import Location ->>>>>>> master from .InputEmulation import Mouse, Keyboard from .App import App from .Exceptions import FindFailed, ImageMissing diff --git a/tests/appveyor_test_cases.py b/tests/appveyor_test_cases.py index 1cdf2f3..f9adc4b 100644 --- a/tests/appveyor_test_cases.py +++ b/tests/appveyor_test_cases.py @@ -18,6 +18,21 @@ def test_movement(self): self.assertEqual(self.mouse.getPos().getTuple(), (100, 200)) lackey.wheel(self.mouse.getPos(), 0, 3) # Mostly just verifying it doesn't crash +class TestKeyboardMethods(unittest.TestCase): + def setUp(self): + self.kb = lackey.Keyboard() + + def test_keys(self): + self.kb.keyDown("{SHIFT}") + self.kb.keyUp("{CTRL}") + self.kb.keyUp("{SHIFT}") + self.kb.type("{CTRL}") + # Really this should check to make sure these keys have all been released, but + # I'm not sure how to make that work without continuously monitoring the keyboard + # (which is the usual scenario). Ah well... if your computer is acting weird after + # you run this test, the SHIFT, CTRL, or ALT keys might not have been released + # properly. + class TestAppMethods(unittest.TestCase): def test_getters(self): if sys.platform.startswith("win"): @@ -28,34 +43,65 @@ def test_getters(self): app2.open() lackey.sleep(1) app2.close() + app.focus() + self.assertEqual(app.getName(), "notepad.exe") + self.assertTrue(app.isRunning()) + self.assertEqual(app.getWindow(), "test_cases.py - Notepad") + self.assertNotEqual(app.getPID(), -1) + region = app.window() + self.assertIsInstance(region, lackey.Region) + self.assertGreater(region.getW(), 0) + self.assertGreater(region.getH(), 0) + app.close() + elif sys.platform == "darwin": + a = lackey.App("+open -a TextEdit tests/test_cases.py") + a2 = lackey.App("open -a TextEdit tests/appveyor_test_cases.py") + lackey.sleep(1) + app = lackey.App("test_cases.py") + app2 = lackey.App("appveyor_test_cases.py") + #app.setUsing("test_cases.py") + lackey.sleep(1) + app2.close() + app.focus() + print(app.getPID()) + self.assertEqual(app.getName()[-len("TextEdit"):], "TextEdit") + self.assertTrue(app.isRunning()) + #self.assertEqual(app.getWindow(), "test_cases.py") # Doesn't work on `open`-triggered apps + self.assertNotEqual(app.getPID(), -1) + region = app.window() + self.assertIsInstance(region, lackey.Region) + self.assertGreater(region.getW(), 0) + self.assertGreater(region.getH(), 0) + app.close() else: - raise NotImplementedError("Platforms supported include: Windows") - app.focus() - - self.assertEqual(app.getName(), "notepad.exe") - self.assertTrue(app.isRunning()) - self.assertEqual(app.getWindow(), "test_cases.py - Notepad") - self.assertNotEqual(app.getPID(), -1) - region = app.window() - self.assertIsInstance(region, lackey.Region) - self.assertGreater(region.getW(), 0) - self.assertGreater(region.getH(), 0) + raise NotImplementedError("Platforms supported include: Windows, OS X") + def test_launchers(self): if sys.platform.startswith("win"): + app = lackey.App("notepad.exe") + app.setUsing("tests\\test_cases.py") + app.open() + lackey.wait(1) + self.assertEqual(app.getName(), "notepad.exe") + self.assertTrue(app.isRunning()) + self.assertEqual(app.getWindow(), "test_cases.py - Notepad") + self.assertNotEqual(app.getPID(), -1) app.close() - lackey.sleep(1.0) - - def test_launchers(self): - app = lackey.App("notepad.exe") - app.setUsing("tests\\test_cases.py") - app.open() - lackey.wait(1) - self.assertEqual(app.getName(), "notepad.exe") - self.assertTrue(app.isRunning()) - self.assertEqual(app.getWindow(), "test_cases.py - Notepad") - self.assertNotEqual(app.getPID(), -1) - app.close() - lackey.wait(0.9) + lackey.wait(0.9) + elif sys.platform.startswith("darwin"): + a = lackey.App("open") + a.setUsing("-a TextEdit tests/test_cases.py") + a.open() + lackey.wait(1) + app = lackey.App("test_cases.py") + self.assertEqual(app.getName()[-len("TextEdit"):], "TextEdit") + self.assertTrue(app.isRunning()) + #self.assertEqual(app.getWindow(), "test_cases.py") # Doesn't work on `open`-triggered apps + self.assertNotEqual(app.getPID(), -1) + app.close() + lackey.wait(0.9) + else: + raise NotImplementedError("Platforms supported include: Windows, OS X") class TestScreenMethods(unittest.TestCase): def setUp(self): @@ -116,7 +162,6 @@ def test_screen_methods(self): class TestPatternMethods(unittest.TestCase): def setUp(self): -<<<<<<< HEAD self.file_path = os.path.join("tests", "test_pattern.png") self.pattern = lackey.Pattern(self.file_path) @@ -125,16 +170,6 @@ def test_defaults(self): self.assertIsInstance(self.pattern.offset, lackey.Location) self.assertEqual(self.pattern.offset.getTuple(), (0,0)) self.assertEqual(self.pattern.path[-len(self.file_path):], self.file_path) -======= - self.pattern = lackey.Pattern("tests\\test_pattern.png") - - def test_defaults(self): - self.assertEqual(self.pattern.similarity, 0.7) - self.assertIsInstance(self.pattern.offset, lackey.Location) - self.assertEqual(self.pattern.offset.getTuple(), (0, 0)) - self.assertEqual(self.pattern.path[-len("tests\\test_pattern.png"):], "tests\\test_pattern.png") ->>>>>>> master - def test_setters(self): test_pattern = self.pattern.similar(0.5) @@ -145,20 +180,12 @@ def test_setters(self): self.assertEqual(test_pattern.path[-len(self.file_path):], self.file_path) test_pattern = self.pattern.targetOffset(3, 5) self.assertEqual(test_pattern.similarity, 0.7) -<<<<<<< HEAD self.assertEqual(test_pattern.path[-len(self.file_path):], self.file_path) self.assertEqual(test_pattern.offset.getTuple(), (3,5)) def test_getters(self): self.assertEqual(self.pattern.getFilename()[-len(self.file_path):], self.file_path) self.assertEqual(self.pattern.getTargetOffset().getTuple(), (0,0)) -======= - self.assertEqual(test_pattern.path[-len("tests\\test_pattern.png"):], "tests\\test_pattern.png") - self.assertEqual(test_pattern.offset.getTuple(), (3, 5)) - - def test_getters(self): - self.assertEqual(self.pattern.getFilename()[-len("tests\\test_pattern.png"):], "tests\\test_pattern.png") - self.assertEqual(self.pattern.getTargetOffset().getTuple(), (0, 0)) self.assertEqual(self.pattern.getSimilar(), 0.7) def test_constructor(self): @@ -291,7 +318,6 @@ def test_settings(self): self.r.setWaitScanRate(2) self.assertEqual(self.r.getWaitScanRate(), 2.0) ->>>>>>> master class TestObserverEventMethods(unittest.TestCase): def setUp(self): self.r = lackey.Screen(0) diff --git a/tests/test_cases.py b/tests/test_cases.py index f18c975..81a13e3 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -16,135 +16,6 @@ except NameError: basestring = str -class TestKeyboardMethods(unittest.TestCase): - def setUp(self): - self.kb = lackey.Keyboard() - - def test_keys(self): - self.kb.keyDown("{SHIFT}") - self.kb.keyUp("{CTRL}") - self.kb.keyUp("{SHIFT}") - self.kb.type("{CTRL}") - # Really this should check to make sure these keys have all been released, but - # I'm not sure how to make that work without continuously monitoring the keyboard - # (which is the usual scenario). Ah well... if your computer is acting weird after - # you run this test, the SHIFT, CTRL, or ALT keys might not have been released - # properly. - -<<<<<<< HEAD -class TestAppMethods(unittest.TestCase): - def test_getters(self): - if sys.platform.startswith("win"): - app = lackey.App("notepad.exe tests\\test_cases.py") - app2 = lackey.App("notepad.exe tests\\test_cases.py") - #app.setUsing("test_cases.py") - app.open() - app2.open() - lackey.sleep(1) - app2.close() - app.focus() - self.assertEqual(app.getName(), "(open)") - self.assertTrue(app.isRunning()) - self.assertEqual(app.getWindow(), "test_cases.py - Notepad") - self.assertNotEqual(app.getPID(), -1) - region = app.window() - self.assertIsInstance(region, lackey.Region) - self.assertGreater(region.getW(), 0) - self.assertGreater(region.getH(), 0) - app.close() - elif sys.platform == "darwin": - a = lackey.App("+open -a TextEdit tests/test_cases.py") - a2 = lackey.App("open -a TextEdit tests/appveyor_test_cases.py") - lackey.sleep(1) - app = lackey.App("test_cases.py") - app2 = lackey.App("appveyor_test_cases.py") - #app.setUsing("test_cases.py") - lackey.sleep(1) - app2.close() - app.focus() - print(app.getPID()) - self.assertEqual(app.getName()[-len("TextEdit"):], "TextEdit") - self.assertTrue(app.isRunning()) - #self.assertEqual(app.getWindow(), "test_cases.py") # Doesn't work on `open`-triggered apps - self.assertNotEqual(app.getPID(), -1) - region = app.window() - self.assertIsInstance(region, lackey.Region) - self.assertGreater(region.getW(), 0) - self.assertGreater(region.getH(), 0) - app.close() - else: - raise NotImplementedError("Platforms supported include: Windows, OS X") - - def test_launchers(self): - if sys.platform.startswith("win"): - app = lackey.App("notepad.exe") - app.setUsing("tests\\test_cases.py") - app.open() - lackey.wait(1) - self.assertEqual(app.getName(), "(open)") - self.assertTrue(app.isRunning()) - self.assertEqual(app.getWindow(), "test_cases.py") - self.assertNotEqual(app.getPID(), -1) - app.close() - lackey.wait(0.9) - elif sys.platform.startswith("darwin"): - a = lackey.App("open") - a.setUsing("-a TextEdit tests/test_cases.py") - a.open() - lackey.wait(1) - app = lackey.App("test_cases.py") - self.assertEqual(app.getName()[-len("TextEdit"):], "TextEdit") - self.assertTrue(app.isRunning()) - #self.assertEqual(app.getWindow(), "test_cases.py") # Doesn't work on `open`-triggered apps - self.assertNotEqual(app.getPID(), -1) - app.close() - lackey.wait(0.9) - else: - raise NotImplementedError("Platforms supported include: Windows, OS X") - - def test_app_title(self): - """ - App selected by title should capture existing window if open, - including case-insensitive matches. - """ - if sys.platform.startswith("win"): - app = lackey.App("notepad.exe") - app.open() - lackey.wait(1) - app2 = lackey.App("Notepad") - app3 = lackey.App("notepad") - lackey.wait(1) - - self.assertTrue(app2.isRunning()) - self.assertTrue(app3.isRunning()) - self.assertEqual(app2.getName(), app.getName()) - self.assertEqual(app3.getName(), app.getName()) - app.close() - elif sys.platform.startswith("darwin"): - pass # Skip this test, due to issues with `open` not being the window owner on Mac - else: - raise NotImplementedError("Platforms supported include: Windows, OS X") - - -class TestScreenMethods(unittest.TestCase): - def setUp(self): - self.primaryScreen = lackey.Screen(0) - - def testScreenInfo(self): - self.assertGreater(self.primaryScreen.getNumberScreens(), 0) - x,y,w,h = self.primaryScreen.getBounds() - self.assertEqual(x, 0) # Top left corner of primary screen should be 0,0 - self.assertEqual(y, 0) # Top left corner of primary screen should be 0,0 - self.assertGreater(w, 0) # Primary screen should be wider than 0 - self.assertGreater(h, 0) # Primary screen should be taller than 0 - - def testCapture(self): - tpath = self.primaryScreen.capture() - self.assertIsInstance(tpath, basestring) - self.assertNotEqual(tpath, "") - -======= ->>>>>>> master class TestComplexFeatures(unittest.TestCase): def setUp(self): print(os.path.dirname(__file__)) From 34cd1e4876c5708da007c4b3b4fa369378b0b610 Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Mon, 15 May 2017 09:20:48 -0400 Subject: [PATCH 07/37] Fixed highlighter, scaling issue --- lackey/PlatformManagerDarwin.py | 44 +++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/lackey/PlatformManagerDarwin.py b/lackey/PlatformManagerDarwin.py index da972c7..272654d 100644 --- a/lackey/PlatformManagerDarwin.py +++ b/lackey/PlatformManagerDarwin.py @@ -7,8 +7,10 @@ import subprocess try: import Tkinter as tk + import Queue as queue except ImportError: import tkinter as tk + import queue import numpy import AppKit @@ -238,6 +240,7 @@ def _getVirtualScreenBitmap(self): for filename, screen in zip(filenames, screen_details): im = Image.open(filename) im.load() + im = im.resize((int(im.size[0]/2), int(im.size[1]/2)), Image.ANTIALIAS) # Capture virtscreen coordinates of monitor x, y, w, h = screen["rect"] # Convert to image-local coordinates @@ -362,6 +365,18 @@ def highlight(self, rect, color="red", seconds=None): If a Tkinter root window has already been created somewhere else, uses that instead of creating a new one. """ + self.queue = queue.Queue() + if seconds == 0: + t = threading.Thread(target=self._do_until_timeout, args=(self.queue,(rect,color,seconds))) + t.start() + q = self.queue + control_obj = lambda: None + control_obj.close = lambda: q.put(True) + return control_obj + self._do_until_timeout(self.queue, (rect,color,seconds)) + + def _do_until_timeout(self, queue, args): + rect, color, seconds = args if tk._default_root is None: Debug.log(3, "Creating new temporary Tkinter root") temporary_root = True @@ -372,11 +387,7 @@ def highlight(self, rect, color="red", seconds=None): temporary_root = False root = tk._default_root image_to_show = self.getBitmapFromRect(*rect) - app = highlightWindow(root, rect, color, image_to_show) - if seconds == 0: - t = threading.Thread(target=app.do_until_timeout) - t.start() - return app + app = highlightWindow(root, rect, color, image_to_show, queue) app.do_until_timeout(seconds) ## Process functions @@ -405,11 +416,15 @@ def getProcessName(self, pid): ## Helper class for highlighting class highlightWindow(tk.Toplevel): - def __init__(self, root, rect, frame_color, screen_cap): + def __init__(self, root, rect, frame_color, screen_cap, queue): """ Accepts rect as (x,y,w,h) """ self.root = root + self.root.tk.call('tk', 'scaling', 0.5) tk.Toplevel.__init__(self, self.root, bg="red", bd=0) + self.queue = queue + self.check_close_after = None + ## Set toplevel geometry, remove borders, and push to the front self.geometry("{2}x{3}+{0}+{1}".format(*rect)) self.overrideredirect(1) @@ -423,7 +438,7 @@ def __init__(self, root, rect, frame_color, screen_cap): bd=0, bg="blue", highlightthickness=0) - self.tk_image = ImageTk.PhotoImage(Image.fromarray(screen_cap[..., [2, 1, 0]])) + self.tk_image = ImageTk.PhotoImage(Image.fromarray(screen_cap)) self.canvas.create_image(0, 0, image=self.tk_image, anchor=tk.NW) self.canvas.create_rectangle( 2, @@ -438,9 +453,22 @@ def __init__(self, root, rect, frame_color, screen_cap): self.lift() self.update() def do_until_timeout(self, seconds=None): + self.check_close() if seconds is not None: - self.root.after(seconds*1000, self.root.destroy) + self.root.after(seconds*1000, self.close) self.root.mainloop() + def check_close(self): + try: + kill = self.queue.get_nowait() + if kill == True: + self.close() + return + except queue.Empty: + pass + + self.check_close_after = self.root.after(500, self.check_close) def close(self): + if self.check_close_after is not None: + self.root.after_cancel(self.check_close_after) self.root.destroy() From 56da4b89f861a396798bb2e0e677de4156781c0c Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Tue, 23 May 2017 11:46:19 -0400 Subject: [PATCH 08/37] OS X debugging/test cases --- tests/mac_test_text.png | Bin 0 -> 5386 bytes tests/preview_open.png | Bin 17297 -> 12756 bytes tests/test_cases.py | 82 +++++++++++++++++++++++++++++----------- tests/test_file_rtf.png | Bin 0 -> 10259 bytes tests/textedit.png | Bin 0 -> 14309 bytes tests/textedit_save.png | Bin 0 -> 17661 bytes 6 files changed, 59 insertions(+), 23 deletions(-) create mode 100644 tests/mac_test_text.png create mode 100644 tests/test_file_rtf.png create mode 100644 tests/textedit.png create mode 100644 tests/textedit_save.png diff --git a/tests/mac_test_text.png b/tests/mac_test_text.png new file mode 100644 index 0000000000000000000000000000000000000000..9e548721bc85e8cc8d4dcfad4208d6928a253546 GIT binary patch literal 5386 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 delta 14402 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 DJO6=& literal 0 HcmV?d00001 diff --git a/tests/textedit.png b/tests/textedit.png new file mode 100644 index 0000000000000000000000000000000000000000..1002b4624e03afab23ba29d5bec998bbe2bbd178 GIT binary patch literal 14309 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 OYZ(az@t>jw0sjYyrKvyw literal 0 HcmV?d00001 diff --git a/tests/textedit_save.png b/tests/textedit_save.png new file mode 100644 index 0000000000000000000000000000000000000000..1542bf16fa20d279400a3fa1a89a60c849730fb3 GIT binary patch literal 17661 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 Lu6{1-oD!M Date: Mon, 12 Jun 2017 00:36:06 +0200 Subject: [PATCH 09/37] Change CONTROL constant to CTRL --- lackey/KeyCodes.py | 2 +- tests/multiprocessing_test.py | 2 +- tests/test_cases.py | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lackey/KeyCodes.py b/lackey/KeyCodes.py index c974c3d..806006e 100644 --- a/lackey/KeyCodes.py +++ b/lackey/KeyCodes.py @@ -44,7 +44,7 @@ class Key(): PRINTSCREEN = "{PRINTSCREEN}" ALT = "{ALT}" CMD = "{CMD}" - CONTROL = "{CTRL}" + CTRL = "{CTRL}" META = "{META}" SHIFT = "{SHIFT}" WIN = "{WIN}" diff --git a/tests/multiprocessing_test.py b/tests/multiprocessing_test.py index c02fa8c..55657d7 100644 --- a/tests/multiprocessing_test.py +++ b/tests/multiprocessing_test.py @@ -23,7 +23,7 @@ def main(): time.sleep(7) r.rightClick(lackey.Pattern("test_text.png").similar(0.6)) r.click("select_all.png") - r.type("c", lackey.Key.CONTROL) # Copy + r.type("c", lackey.Key.CTRL) # Copy assert r.getClipboard() == "This is a test" r.type("{DELETE}") r.type("{F4}", lackey.Key.ALT) diff --git a/tests/test_cases.py b/tests/test_cases.py index d164e81..0f082e5 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -47,13 +47,13 @@ def testTypeCopyPaste(self): r = app.window() r.type("This is a Test") - r.type("a", lackey.Key.CONTROL) # Select all - r.type("c", lackey.Key.CONTROL) # Copy + r.type("a", lackey.Key.CTRL) # Select all + r.type("c", lackey.Key.CTRL) # Copy self.assertEqual(r.getClipboard(), "This is a Test") r.type("{DELETE}") # Clear the selected text r.paste("This, on the other hand, is a {SHIFT}broken {SHIFT}record.") # Paste should ignore special characters and insert the string as is - r.type("a", lackey.Key.CONTROL) # Select all - r.type("c", lackey.Key.CONTROL) # Copy + r.type("a", lackey.Key.CTRL) # Select all + r.type("c", lackey.Key.CTRL) # Copy self.assertEqual(r.getClipboard(), "This, on the other hand, is a {SHIFT}broken {SHIFT}record.") if sys.platform.startswith("win"): @@ -84,7 +84,7 @@ def test_observer(appear_event): r.rightClick(r.getLastMatch()) self.assertGreater(r.getTime(), 0) r.click("select_all.png") - r.type("c", lackey.Key.CONTROL) # Copy + r.type("c", lackey.Key.CTRL) # Copy self.assertEqual(r.getClipboard(), "This is a test") r.type("{DELETE}") r.type("{F4}", lackey.Key.ALT) From fb2325fbec5a268c249bd562ec62257ddda83c45 Mon Sep 17 00:00:00 2001 From: nejch Date: Mon, 12 Jun 2017 23:45:47 +0200 Subject: [PATCH 10/37] Fix delay when parsing key codes --- lackey/InputEmulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lackey/InputEmulation.py b/lackey/InputEmulation.py index 4cf0ba0..ce3322b 100644 --- a/lackey/InputEmulation.py +++ b/lackey/InputEmulation.py @@ -376,6 +376,6 @@ def type(self, text, delay=0.1): keyboard.press(self._SPECIAL_KEYCODES["SHIFT"]) keyboard.press_and_release(self._UPPERCASE_KEYCODES[text[i]]) keyboard.release(self._SPECIAL_KEYCODES["SHIFT"]) - if delay: + if delay and not in_special_code: time.sleep(delay) From a4c1a9fad4cff0cf928f02c1632d8de719d75986 Mon Sep 17 00:00:00 2001 From: nejch Date: Fri, 23 Jun 2017 00:33:17 +0200 Subject: [PATCH 11/37] Add test for type() failing with multiple codes --- tests/test_cases.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/tests/test_cases.py b/tests/test_cases.py index 0f082e5..4264fd0 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -16,9 +16,24 @@ except NameError: basestring = str +@unittest.skipUnless(sys.platform.startswith("win"), "Platforms supported include: Windows") class TestKeyboardMethods(unittest.TestCase): def setUp(self): self.kb = lackey.Keyboard() + self.app = lackey.App("notepad.exe") + self.app.open() + time.sleep(1) + + def tearDown(self): + self.app.close() + + def type_and_check_equals(self, typed, expected): + self.kb.type(typed) + time.sleep(0.2) + lackey.type("a", lackey.Key.CTRL) + lackey.type("c", lackey.Key.CTRL) + + self.assertEqual(lackey.getClipboard(), expected) def test_keys(self): self.kb.keyDown("{SHIFT}") @@ -31,6 +46,24 @@ def test_keys(self): # you run this test, the SHIFT, CTRL, or ALT keys might not have been released # properly. + def test_parsed_special_codes(self): + OUTPUTS = { + # Special codes should output the text below. + # Multiple special codes should be parsed correctly. + # False special codes should be typed out normally. + "{SPACE}": " ", + "{TAB}": "\t", + "{SPACE}{SPACE}": " ", + "{TAB}{TAB}": "\t\t", + "{ENTER}{ENTER}": "\r\n\r\n", + "{TEST}": "{TEST}", + "{TEST}{TEST}": "{TEST}{TEST}" + } + + for code in OUTPUTS: + self.type_and_check_equals(code, OUTPUTS[code]) + + class TestComplexFeatures(unittest.TestCase): def setUp(self): print(os.path.dirname(__file__)) From 7b645f8d32525e771f1eca1ca36fe3dccc209175 Mon Sep 17 00:00:00 2001 From: nejch Date: Fri, 23 Jun 2017 00:51:31 +0200 Subject: [PATCH 12/37] Add missing special_code reset in type() method --- lackey/InputEmulation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/lackey/InputEmulation.py b/lackey/InputEmulation.py index ce3322b..30a35b5 100644 --- a/lackey/InputEmulation.py +++ b/lackey/InputEmulation.py @@ -367,6 +367,7 @@ def type(self, text, delay=0.1): # Release the rest of the keys normally self.type(special_code) self.type(text[i]) + special_code = "" elif in_special_code: special_code += text[i] elif text[i] in self._REGULAR_KEYCODES.keys(): From f960e09cbd323f96053d7c33f3bff263e19703c2 Mon Sep 17 00:00:00 2001 From: nejch Date: Sun, 25 Jun 2017 00:46:49 +0200 Subject: [PATCH 13/37] Fix tests failing if file extensions hidden in Windows --- tests/appveyor_test_cases.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/appveyor_test_cases.py b/tests/appveyor_test_cases.py index 95600c4..dd128b2 100644 --- a/tests/appveyor_test_cases.py +++ b/tests/appveyor_test_cases.py @@ -7,6 +7,12 @@ import os import lackey +# Python 2/3 compatibility +try: + unittest.TestCase.assertRegex +except AttributeError: + unittest.TestCase.assertRegex = unittest.TestCase.assertRegexpMatches + class TestMouseMethods(unittest.TestCase): def setUp(self): self.mouse = lackey.Mouse() @@ -34,7 +40,7 @@ def test_getters(self): self.assertEqual(app.getName(), "notepad.exe") self.assertTrue(app.isRunning()) - self.assertEqual(app.getWindow(), "test_cases.py - Notepad") + self.assertRegex(app.getWindow(), "test_cases(.py)? - Notepad") self.assertNotEqual(app.getPID(), -1) region = app.window() self.assertIsInstance(region, lackey.Region) @@ -52,7 +58,7 @@ def test_launchers(self): lackey.wait(1) self.assertEqual(app.getName(), "notepad.exe") self.assertTrue(app.isRunning()) - self.assertEqual(app.getWindow(), "test_cases.py - Notepad") + self.assertRegex(app.getWindow(), "test_cases(.py)? - Notepad") self.assertNotEqual(app.getPID(), -1) app.close() lackey.wait(0.9) From 1016d54cfd8df34d13def619e880537810717ca3 Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Mon, 17 Jul 2017 18:11:13 -0400 Subject: [PATCH 14/37] Minor tweaks --- lackey/InputEmulation.py | 7 +++++-- tests/appveyor_test_cases.py | 1 + tests/test_cases.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/lackey/InputEmulation.py b/lackey/InputEmulation.py index 890bed0..a45f076 100644 --- a/lackey/InputEmulation.py +++ b/lackey/InputEmulation.py @@ -29,7 +29,7 @@ def __init__(self): def move(self, loc, yoff=None): """ Moves cursor to specified location. Accepts the following arguments: - + * ``move(loc)`` - Move cursor to ``Location`` * ``move(xoff, yoff) - Move cursor to offset from current location """ @@ -37,8 +37,11 @@ def move(self, loc, yoff=None): self._lock.acquire() if isinstance(loc, Location): mouse.move(loc.x, loc.y) + elif yoff is not None: + xoff = loc + mouse.move(xoff, yoff) else: - mouse.move(loc, yoff) + raise ValueError("Invalid argument. Expected either move(loc) or move(xoff, yoff).") self._last_position = loc self._lock.release() diff --git a/tests/appveyor_test_cases.py b/tests/appveyor_test_cases.py index f9adc4b..97d4273 100644 --- a/tests/appveyor_test_cases.py +++ b/tests/appveyor_test_cases.py @@ -13,6 +13,7 @@ def setUp(self): def test_movement(self): self.mouse.move(lackey.Location(10, 10)) + lackey.sleep(0.01) self.assertEqual(self.mouse.getPos().getTuple(), (10, 10)) self.mouse.moveSpeed(lackey.Location(100, 200), 0.5) self.assertEqual(self.mouse.getPos().getTuple(), (100, 200)) diff --git a/tests/test_cases.py b/tests/test_cases.py index 90a12c4..bb71720 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -39,7 +39,7 @@ def testTypeCopyPaste(self): r.type("c", lackey.Key.CONTROL) # Copy self.assertEqual(r.getClipboard(), "This, on the other hand, is a {SHIFT}broken {SHIFT}record.") elif sys.platform == "darwin": - app = lackey.App("+/Applications/TextEdit.app/Contents/MacOS/TextEdit") + app = lackey.App("+open -e") lackey.sleep(2) #r.debugPreview() r.wait(lackey.Pattern("preview_open.png")) From 70c25fe915772620aa0ede3e3714dc7a13cf5224 Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Mon, 17 Jul 2017 18:38:09 -0400 Subject: [PATCH 15/37] Added FOREVER constant for #99 --- lackey/RegionMatching.py | 8 ++++++++ lackey/__init__.py | 2 +- tests/test_cases.py | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lackey/RegionMatching.py b/lackey/RegionMatching.py index 3adca44..913db84 100644 --- a/lackey/RegionMatching.py +++ b/lackey/RegionMatching.py @@ -41,6 +41,11 @@ basestring except NameError: basestring = str +try: + FOREVER = float("inf") +except: + import math + FOREVER = math.inf # Instantiate input emulation objects Mouse = MouseClass() @@ -566,6 +571,9 @@ def wait(self, pattern, seconds=None): Sikuli supports OCR search with a text parameter. This does not (yet). """ if isinstance(pattern, (int, float)): + if pattern == FOREVER: + while True: + time.sleep(1) # Infinite loop time.sleep(pattern) return None diff --git a/lackey/__init__.py b/lackey/__init__.py index 96ffe8b..20b441c 100644 --- a/lackey/__init__.py +++ b/lackey/__init__.py @@ -28,7 +28,7 @@ #from .PlatformManagerWindows import PlatformManagerWindows from .KeyCodes import Button, Key, KeyModifier -from .RegionMatching import Pattern, Region, Match, Screen, ObserveEvent, PlatformManager +from .RegionMatching import Pattern, Region, Match, Screen, ObserveEvent, PlatformManager, FOREVER from .Geometry import Location from .InputEmulation import Mouse, Keyboard from .App import App diff --git a/tests/test_cases.py b/tests/test_cases.py index 0fa3ad9..4d0a816 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -90,7 +90,7 @@ def testTypeCopyPaste(self): app = lackey.App("+open -e") lackey.sleep(2) #r.debugPreview() - r.wait(lackey.Pattern("preview_open.png")) + r.wait(lackey.Pattern("preview_open.png"), lackey.FOREVER) r.click(lackey.Pattern("preview_open.png")) lackey.type("n", lackey.KeyModifier.CMD) time.sleep(1) From 8917e08725ba6d00bddfd5fa073a483032d19115 Mon Sep 17 00:00:00 2001 From: gitfish Date: Fri, 1 Sep 2017 10:49:50 -0700 Subject: [PATCH 16/37] * modify ansi to unicode function to try avoiding access violation --- lackey/PlatformManagerWindows.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lackey/PlatformManagerWindows.py b/lackey/PlatformManagerWindows.py index 33cd799..c130561 100644 --- a/lackey/PlatformManagerWindows.py +++ b/lackey/PlatformManagerWindows.py @@ -303,8 +303,8 @@ class BITMAPINFO(ctypes.Structure): DIB_RGB_COLORS = 0 ## Begin logic - self._gdi32.CreateDCA.restype = ctypes.c_void_p - hdc = self._gdi32.CreateDCA(ctypes.c_char_p(device_name.encode("utf-8")), 0, 0, 0) # Convert to bytestring for c_char_p type + self._gdi32.CreateDCW.restype = ctypes.c_void_p + hdc = self._gdi32.CreateDCW(ctypes.c_wchar_p(unicode(device_name)), 0, 0, 0) # Convert to bytestring for c_char_p type if hdc == 0: raise ValueError("Empty hdc provided") From 5eb8fce2cd3bdd398f7b9d9e76bd1daf85e619d5 Mon Sep 17 00:00:00 2001 From: gitfish Date: Fri, 1 Sep 2017 14:25:38 -0700 Subject: [PATCH 17/37] * update comment --- lackey/PlatformManagerWindows.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lackey/PlatformManagerWindows.py b/lackey/PlatformManagerWindows.py index c130561..be35fc9 100644 --- a/lackey/PlatformManagerWindows.py +++ b/lackey/PlatformManagerWindows.py @@ -304,7 +304,7 @@ class BITMAPINFO(ctypes.Structure): ## Begin logic self._gdi32.CreateDCW.restype = ctypes.c_void_p - hdc = self._gdi32.CreateDCW(ctypes.c_wchar_p(unicode(device_name)), 0, 0, 0) # Convert to bytestring for c_char_p type + hdc = self._gdi32.CreateDCW(ctypes.c_wchar_p(unicode(device_name)), 0, 0, 0) # Convert to bytestring for c_wchar_p type if hdc == 0: raise ValueError("Empty hdc provided") From c91367f5d049b8471f0fb92c1f6380b130d4a1cc Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Thu, 12 Oct 2017 08:12:10 -0400 Subject: [PATCH 18/37] Updated version, readme, Travis config --- .travis.yml | 59 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 +- lackey/_version.py | 2 +- 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..654a029 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,59 @@ +matrix: + include: + - os: osx + language: generic + env: PYTHON=2.7 + - os: osx + language: generic + env: PYTHON=3.5 + - os: osx + language: generic + env: PYTHON=3.6 + - os: osx + language: generic + env: PYTHON=3.7 + +before_install: | + if [ "$TRAVIS_OS_NAME" == "osx" ]; then + brew update + # Per the `pyenv homebrew recommendations `_. + brew install openssl readline + # See https://docs.travis-ci.com/user/osx-ci-environment/#A-note-on-upgrading-packages. + # I didn't do this above because it works and I'm lazy. + brew outdated pyenv || brew upgrade pyenv + # virtualenv doesn't work without pyenv knowledge. venv in Python 3.3 + # doesn't provide Pip by default. So, use `pyenv-virtualenv `_. + brew install pyenv-virtualenv + pyenv install $PYTHON + # I would expect something like ``pyenv init; pyenv local $PYTHON`` or + # ``pyenv shell $PYTHON`` would work, but ``pyenv init`` doesn't seem to + # modify the Bash environment. ??? So, I hand-set the variables instead. + export PYENV_VERSION=$PYTHON + export PATH="/Users/travis/.pyenv/shims:${PATH}" + pyenv-virtualenv venv + source venv/bin/activate + # A manual check that the correct version of Python is running. + python --version + fi + +install: + - python -m pip install --disable-pip-version-check --user --upgrade pip + - python -m easy_install -U setuptools + +build_script: + # Build the compiled extension + - python setup.py install + +test_script: + # Run the project tests + - python tests/appveyor_test_cases.py + +after_test: + # If tests are successful, create binary packages for the project. + - python setup.py bdist_wheel + - python setup.py sdist + - ls dist + +artifacts: + # Archive the generated packages in the ci.appveyor.com build report. + - path: dist\* \ No newline at end of file diff --git a/README.md b/README.md index 985e0fb..bc3a784 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ In my line of work, I have a lot of tasks walking through line-of-business appli There are some existing libraries for this purpose, like `pywinauto` and `autopy`, but they didn't work for me for one reason or another. I wasn't doing a lot of Windows GUI interaction with these particular applications, so `pywinauto`'s approach wouldn't help. I needed something that could search for and use images on screen. `autopy` was closer, but it had quite a few outstanding issues and hadn't been updated in a while. -Most of my automation is in Windows, so I've begun this library with only Windows support. However, it's designed to eventually be extended with support for Mac OS X and Linux by implementing additional "PlatformManager" classes. I'll get around to these at some point, but if you'd like to contribute one sooner, please feel free! +Most of my automation is in Windows, so I've begun this library with only Windows support. As of version 0.7.0, it also includes Mac OS X support,and it's designed to eventually be extended with support for Linux by implementing an additional "PlatformManager" class. I'll get around to this at some point, but if you'd like to contribute one sooner, please feel free! ### Sikuli Patching ### diff --git a/lackey/_version.py b/lackey/_version.py index c3cb368..39c4d75 100644 --- a/lackey/_version.py +++ b/lackey/_version.py @@ -2,5 +2,5 @@ """ -__version__ = "0.6.1" +__version__ = "0.7.0" __sikuli_version__ = "1.1.0" From ae6f00e5aca2453e72697958efac18f0da27f173 Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Thu, 12 Oct 2017 08:33:38 -0400 Subject: [PATCH 19/37] Fixed Travis build steps --- .travis.yml | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/.travis.yml b/.travis.yml index 654a029..4700699 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,13 +5,10 @@ matrix: env: PYTHON=2.7 - os: osx language: generic - env: PYTHON=3.5 + env: PYTHON=3.5.4 - os: osx language: generic - env: PYTHON=3.6 - - os: osx - language: generic - env: PYTHON=3.7 + env: PYTHON=3.6.3 before_install: | if [ "$TRAVIS_OS_NAME" == "osx" ]; then @@ -32,28 +29,22 @@ before_install: | export PATH="/Users/travis/.pyenv/shims:${PATH}" pyenv-virtualenv venv source venv/bin/activate - # A manual check that the correct version of Python is running. - python --version fi install: + # A manual check that the correct version of Python is running. + - python --version - python -m pip install --disable-pip-version-check --user --upgrade pip - python -m easy_install -U setuptools -build_script: +script: # Build the compiled extension - python setup.py install - -test_script: # Run the project tests - python tests/appveyor_test_cases.py -after_test: +after_success: # If tests are successful, create binary packages for the project. - python setup.py bdist_wheel - python setup.py sdist - - ls dist - -artifacts: - # Archive the generated packages in the ci.appveyor.com build report. - - path: dist\* \ No newline at end of file + - ls dist \ No newline at end of file From b56c62f7f6587c9bec2126271d0f1868662d7dc5 Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Thu, 12 Oct 2017 08:57:05 -0400 Subject: [PATCH 20/37] Changed pyenv setup --- .travis.yml | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4700699..7347193 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,22 +13,22 @@ matrix: before_install: | if [ "$TRAVIS_OS_NAME" == "osx" ]; then brew update - # Per the `pyenv homebrew recommendations `_. brew install openssl readline - # See https://docs.travis-ci.com/user/osx-ci-environment/#A-note-on-upgrading-packages. - # I didn't do this above because it works and I'm lazy. - brew outdated pyenv || brew upgrade pyenv - # virtualenv doesn't work without pyenv knowledge. venv in Python 3.3 - # doesn't provide Pip by default. So, use `pyenv-virtualenv `_. - brew install pyenv-virtualenv + + # install pyenv + git clone --depth 1 https://github.com/pyenv/pyenv ~/.pyenv + PYENV_ROOT="$HOME/.pyenv" + PATH="$PYENV_ROOT/bin:$PATH" + eval "$(pyenv init -)" + + # set up Python environment pyenv install $PYTHON - # I would expect something like ``pyenv init; pyenv local $PYTHON`` or - # ``pyenv shell $PYTHON`` would work, but ``pyenv init`` doesn't seem to - # modify the Bash environment. ??? So, I hand-set the variables instead. - export PYENV_VERSION=$PYTHON - export PATH="/Users/travis/.pyenv/shims:${PATH}" - pyenv-virtualenv venv - source venv/bin/activate + pyenv global $PYTHON + + pyenv rehash + python -m pip install --user virtualenv + python -m virtualenv ~/.venv + source ~/.venv/bin/activate fi install: From 3da64c5e64268a8fd5ea2c0e92a773f8a717273c Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Thu, 12 Oct 2017 09:24:32 -0400 Subject: [PATCH 21/37] Removed --user flags for Travis pip --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7347193..5c05e6b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,7 +26,7 @@ before_install: | pyenv global $PYTHON pyenv rehash - python -m pip install --user virtualenv + python -m pip install virtualenv python -m virtualenv ~/.venv source ~/.venv/bin/activate fi @@ -34,7 +34,7 @@ before_install: | install: # A manual check that the correct version of Python is running. - python --version - - python -m pip install --disable-pip-version-check --user --upgrade pip + - python -m pip install --disable-pip-version-check --upgrade pip - python -m easy_install -U setuptools script: From abb9c1261b003ba57a33a6ae6f69349383ce30f3 Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Thu, 12 Oct 2017 09:51:31 -0400 Subject: [PATCH 22/37] Changed requirements to add pyobjc For Mac OS X only --- setup.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5d307be..70409e7 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,7 @@ """ import re +import platform from setuptools import setup, find_packages from setuptools.dist import Distribution @@ -16,6 +17,10 @@ class BinaryDistribution(Distribution): def is_pure(self): return False +install_requires = ['requests', 'pillow', 'numpy', 'opencv-python', 'keyboard', 'pyperclip'] +if platform.system() == "Darwin": + install_requires += ['pyobjc'] + setup( name="Lackey", description="A Sikuli script implementation in Python", @@ -42,7 +47,7 @@ def is_pure(self): ], keywords="automation testing sikuli", packages=find_packages(exclude=['docs', 'tests']), - install_requires=['requests', 'pillow', 'numpy', 'opencv-python', 'keyboard', 'pyperclip'], + install_requires=install_requires, include_package_data=True, distclass=BinaryDistribution ) From efe2901bf43373e9b852ee406d43c62d03f957aa Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Thu, 12 Oct 2017 10:26:31 -0400 Subject: [PATCH 23/37] Added pyobjc-core to requirements --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 70409e7..6698d92 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ def is_pure(self): install_requires = ['requests', 'pillow', 'numpy', 'opencv-python', 'keyboard', 'pyperclip'] if platform.system() == "Darwin": - install_requires += ['pyobjc'] + install_requires += ['pyobjc', 'pyobjc-core'] setup( name="Lackey", From b1b9f61efd9f42baa14573fea71ed58a8efe0997 Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Thu, 12 Oct 2017 11:24:33 -0400 Subject: [PATCH 24/37] Moved pyobjc install to travis script --- .travis.yml | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5c05e6b..74cd54d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,7 +26,7 @@ before_install: | pyenv global $PYTHON pyenv rehash - python -m pip install virtualenv + python -m pip install virtualenv pyobjc python -m virtualenv ~/.venv source ~/.venv/bin/activate fi diff --git a/setup.py b/setup.py index 6698d92..70409e7 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ def is_pure(self): install_requires = ['requests', 'pillow', 'numpy', 'opencv-python', 'keyboard', 'pyperclip'] if platform.system() == "Darwin": - install_requires += ['pyobjc', 'pyobjc-core'] + install_requires += ['pyobjc'] setup( name="Lackey", From 671cc09db3e9fa98e1f49b3d54c107de295be3e0 Mon Sep 17 00:00:00 2001 From: Nejc Habjan Date: Thu, 12 Oct 2017 19:10:45 +0200 Subject: [PATCH 25/37] Rename underscore functions --- lackey/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lackey/__init__.py b/lackey/__init__.py index 20b441c..252554f 100644 --- a/lackey/__init__.py +++ b/lackey/__init__.py @@ -51,13 +51,13 @@ def _abort_script(): # First, save the native functions by remapping them with a prefixed underscore: -_type = type -_input = input +type_ = type +input_ = input try: - _exit = exit + exit_ = exit except NameError: pass # `exit` is not always defined, as when building to executable. -#_zip = zip +#zip_ = zip ## Sikuli Convenience Functions From 3d06a23354b8566c9bbe143d55f27ad4fad8d1d7 Mon Sep 17 00:00:00 2001 From: Nejc Habjan Date: Thu, 12 Oct 2017 23:52:37 +0200 Subject: [PATCH 26/37] Use sys.exit instead of exit sys.exit() and exit() both do mostly the same thing (raise SystemExit), but sys.exit() is always available after import sys, so this can be simplified. See https://stackoverflow.com/q/6501121 --- lackey/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lackey/__init__.py b/lackey/__init__.py index 252554f..186cabb 100644 --- a/lackey/__init__.py +++ b/lackey/__init__.py @@ -53,10 +53,7 @@ def _abort_script(): type_ = type input_ = input -try: - exit_ = exit -except NameError: - pass # `exit` is not always defined, as when building to executable. +exit_ = sys.exit #zip_ = zip ## Sikuli Convenience Functions From 4a8aebb3bbe287090e8a14663190fedc28edc135 Mon Sep 17 00:00:00 2001 From: Nejc Habjan Date: Fri, 13 Oct 2017 00:34:39 +0200 Subject: [PATCH 27/37] Add deprecated underscore functions --- lackey/__init__.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lackey/__init__.py b/lackey/__init__.py index 186cabb..3d354fe 100644 --- a/lackey/__init__.py +++ b/lackey/__init__.py @@ -22,6 +22,7 @@ import sys import time import os +import warnings import requests ## Lackey sub-files @@ -56,6 +57,22 @@ def _abort_script(): exit_ = sys.exit #zip_ = zip + +# Deprecated underscore functions + +def _exit(code): + warnings.warn("Please use exit_ instead.", DeprecationWarning) + return exit_(code) + +def _input(prompt): + warnings.warn("Please use input_ instead.", DeprecationWarning) + return input_(prompt) + +def _type(obj): + warnings.warn("Please use type_ instead.", DeprecationWarning) + return type_(obj) + + ## Sikuli Convenience Functions def sleep(seconds): From 575b5ff2c5eee65f98c6ec36ec68945a201a7051 Mon Sep 17 00:00:00 2001 From: Nejc Habjan Date: Fri, 13 Oct 2017 00:36:07 +0200 Subject: [PATCH 28/37] Update README and comments --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bc3a784..838bb0f 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,11 @@ My goal with this project is to be able to reuse my existing library of Sikuli s Note that I *have* had to adjust some of my image search similarity settings in a couple cases. Your mileage may vary. Please report any issues that you encounter and I'll try to get them patched. -Be aware that **some Sikuli-script methods actually overwrite Python-native functions**, namely `type()` and `input()`. Where this is the case, I've remapped the native functions by prefixing them with an underscore. They can be accessed as follows: +Be aware that **some Sikuli-script methods actually overwrite Python-native functions**, namely `type()` and `input()`. Where this is the case, I've remapped the native functions by adding a trailing underscore. They can be accessed as follows: from lackey import * - username = _input("Enter your username: ") + username = input_("Enter your username: ") ## Structure ## From 1b2604e9a29241688e6cadb01404cf176e85c86e Mon Sep 17 00:00:00 2001 From: Nejc Habjan Date: Fri, 13 Oct 2017 01:26:13 +0200 Subject: [PATCH 29/37] Add tests for underscore functions --- tests/appveyor_test_cases.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/appveyor_test_cases.py b/tests/appveyor_test_cases.py index 202d568..8662a03 100644 --- a/tests/appveyor_test_cases.py +++ b/tests/appveyor_test_cases.py @@ -567,6 +567,11 @@ def test_function_defs(self): self.assertHasMethod(lackey, "select", 4) self.assertHasMethod(lackey, "popFile", 1) + def test_renamed_builtin_functions(self): + self.assertEqual(lackey.exit_, sys.exit) + self.assertEqual(lackey.input_, input) + self.assertEqual(lackey.type_, type) + def assertHasMethod(self, cls, mthd, args=0): """ Custom test to make sure a class has the specified method (and that it takes `args` parameters) """ self.assertTrue(callable(getattr(cls, mthd, None))) From 1c26735a20d4137ae6cc2c2043311f4310f1f013 Mon Sep 17 00:00:00 2001 From: Nejc Habjan Date: Fri, 13 Oct 2017 01:47:40 +0200 Subject: [PATCH 30/37] Update underscore comment --- lackey/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lackey/__init__.py b/lackey/__init__.py index 3d354fe..7e76eb4 100644 --- a/lackey/__init__.py +++ b/lackey/__init__.py @@ -50,7 +50,7 @@ def _abort_script(): ## Sikuli patching: Functions that map to the global Screen region ## Don't try this at home, kids! -# First, save the native functions by remapping them with a prefixed underscore: +# First, save the native functions by remapping them with a trailing underscore: type_ = type input_ = input From f665fbad793e3085cbe6f763558888323bc982b7 Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Mon, 23 Oct 2017 08:37:59 -0400 Subject: [PATCH 31/37] Added osx_requirements.txt for Travis --- .travis.yml | 3 ++- osx_requirements.txt | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 osx_requirements.txt diff --git a/.travis.yml b/.travis.yml index 74cd54d..b0b21a0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,7 +26,7 @@ before_install: | pyenv global $PYTHON pyenv rehash - python -m pip install virtualenv pyobjc + python -m pip install virtualenv python -m virtualenv ~/.venv source ~/.venv/bin/activate fi @@ -35,6 +35,7 @@ install: # A manual check that the correct version of Python is running. - python --version - python -m pip install --disable-pip-version-check --upgrade pip + - pip install --user -r osx_requirements.txt - python -m easy_install -U setuptools script: diff --git a/osx_requirements.txt b/osx_requirements.txt new file mode 100644 index 0000000..95e09d5 --- /dev/null +++ b/osx_requirements.txt @@ -0,0 +1,13 @@ +# +####### requirements.txt ####### +# +###### Requirements without Version Specifiers ###### +pyobjc +requests +numpy +pillow +opencv-python +wheel +pyperclip +keyboard>=v0.9.13 +twine \ No newline at end of file From 9e41ef362eb790a48ebd14ce08f6e6c5a76a28cb Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Mon, 23 Oct 2017 09:08:42 -0400 Subject: [PATCH 32/37] Tweaking travis.yml --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index b0b21a0..6501cdf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -35,7 +35,7 @@ install: # A manual check that the correct version of Python is running. - python --version - python -m pip install --disable-pip-version-check --upgrade pip - - pip install --user -r osx_requirements.txt + - pip install -r osx_requirements.txt - python -m easy_install -U setuptools script: From 2ca2aca6d5da6b496085e532f0415d4a67b67148 Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Mon, 23 Oct 2017 09:41:32 -0400 Subject: [PATCH 33/37] Skipping TextEdit tests in travis build --- tests/appveyor_test_cases.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/appveyor_test_cases.py b/tests/appveyor_test_cases.py index 8662a03..e6c2de8 100644 --- a/tests/appveyor_test_cases.py +++ b/tests/appveyor_test_cases.py @@ -61,6 +61,8 @@ def test_getters(self): self.assertGreater(region.getH(), 0) app.close() elif sys.platform == "darwin": + if "TRAVIS" in os.environ: + return # Skip these tests in travis build environment a = lackey.App("+open -a TextEdit tests/test_cases.py") a2 = lackey.App("open -a TextEdit tests/appveyor_test_cases.py") lackey.sleep(1) @@ -71,8 +73,8 @@ def test_getters(self): app2.close() app.focus() print(app.getPID()) - self.assertEqual(app.getName()[-len("TextEdit"):], "TextEdit") self.assertTrue(app.isRunning()) + self.assertEqual(app.getName()[-len("TextEdit"):], "TextEdit") #self.assertEqual(app.getWindow(), "test_cases.py") # Doesn't work on `open`-triggered apps self.assertNotEqual(app.getPID(), -1) region = app.window() @@ -96,13 +98,15 @@ def test_launchers(self): app.close() lackey.wait(0.9) elif sys.platform.startswith("darwin"): + if "TRAVIS" in os.environ: + return # Skip these tests in travis build environment a = lackey.App("open") a.setUsing("-a TextEdit tests/test_cases.py") a.open() lackey.wait(1) app = lackey.App("test_cases.py") - self.assertEqual(app.getName()[-len("TextEdit"):], "TextEdit") self.assertTrue(app.isRunning()) + self.assertEqual(app.getName()[-len("TextEdit"):], "TextEdit") #self.assertEqual(app.getWindow(), "test_cases.py") # Doesn't work on `open`-triggered apps self.assertNotEqual(app.getPID(), -1) app.close() From 2124135ae4a9a22cf6a99da5ac29c1743a3b8a4c Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Mon, 23 Oct 2017 12:32:00 -0400 Subject: [PATCH 34/37] Added deploy command to travis --- .travis.yml | 57 ++++++++++++++++++++++------------------------------- 1 file changed, 24 insertions(+), 33 deletions(-) diff --git a/.travis.yml b/.travis.yml index 6501cdf..4ca17aa 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,42 +10,33 @@ matrix: language: generic env: PYTHON=3.6.3 -before_install: | - if [ "$TRAVIS_OS_NAME" == "osx" ]; then - brew update - brew install openssl readline - - # install pyenv - git clone --depth 1 https://github.com/pyenv/pyenv ~/.pyenv - PYENV_ROOT="$HOME/.pyenv" - PATH="$PYENV_ROOT/bin:$PATH" - eval "$(pyenv init -)" - - # set up Python environment - pyenv install $PYTHON - pyenv global $PYTHON - - pyenv rehash - python -m pip install virtualenv - python -m virtualenv ~/.venv - source ~/.venv/bin/activate - fi +before_install: "if [ \"$TRAVIS_OS_NAME\" == \"osx\" ]; then\n brew update\n brew + install openssl readline\n\n # install pyenv\n git clone --depth 1 https://github.com/pyenv/pyenv + ~/.pyenv\n PYENV_ROOT=\"$HOME/.pyenv\"\n PATH=\"$PYENV_ROOT/bin:$PATH\"\n eval + \"$(pyenv init -)\"\n\n # set up Python environment\n pyenv install $PYTHON\n + \ pyenv global $PYTHON\n \n pyenv rehash\n python -m pip install virtualenv\n + \ python -m virtualenv ~/.venv\n source ~/.venv/bin/activate\nfi\n" install: - # A manual check that the correct version of Python is running. - - python --version - - python -m pip install --disable-pip-version-check --upgrade pip - - pip install -r osx_requirements.txt - - python -m easy_install -U setuptools +- python --version +- python -m pip install --disable-pip-version-check --upgrade pip +- pip install -r osx_requirements.txt +- python -m easy_install -U setuptools script: - # Build the compiled extension - - python setup.py install - # Run the project tests - - python tests/appveyor_test_cases.py +- python setup.py install +- python tests/appveyor_test_cases.py after_success: - # If tests are successful, create binary packages for the project. - - python setup.py bdist_wheel - - python setup.py sdist - - ls dist \ No newline at end of file +- python setup.py bdist_wheel +- python setup.py sdist +- ls dist + + +deploy: + provider: pypi + user: glitchassassin + password: + secure: YuUL3YmD6c+X3BBu3bgAhvkFHfE7dMK+dJzPuvSN6MNoYntzDQRHLAARLbs6SYmytHrj1M9MReA43fQOhIi3FSHvS6rOSgiMzfwdz0BUA9KQNCKn/rj56vgMoKJwq2Fp5lEz292VhHSh0qpyGl+k52dTodIpqxFMBKMXoxWBjNam2DXjRTHLKCex3aiGOF9Fm5Vhlwd0fAy+6T3YKuijr++a9B5szt8Ex7iMzlH3DJNYTsPxu5RdUrbKRoNY5SSf+7BgqlLqBvE6qCxgeMxSBgkkaQNjYTY4ZkLtIweWtfMzHFLBUlPfen0E7bMlU1w4tnLVKam/xO7qz+vEwm4LrPQQGtkt8WL5b89oNmQ/e7ocTt1Kw8OU+rCB4VIXXT92qT05dETjloAOL8c7Csl/Gl/DctZyZg3I4gsHw7mkK/wZkZDnAwb0k7pH2ECownYSY7m5QbibDVZzFZGoJwtwuweELeg1zJW89r+w4ZhXuXvRjJB1kYukLrd9POTEdAlp0Cjs0QM0PxD8S9yONjUv2skgK00DF1uJNVN7sF/zNF+ulq3nbQGvN6c8zzUQEOPFKHZoqiEW58Dt+MVAYMIjHRhjVb1rrUYTidJjmwV3+zsfMlCQhChOO4UvXcsXwmO6AIpGRbhmzmdwArylOT9BxR7AcuuI1aDqsQ35p7d+jjU= + on: + tags: true From 55107df3eb783bec8f590d050ca979b870bc88be Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Mon, 23 Oct 2017 15:38:07 -0400 Subject: [PATCH 35/37] Fixed regex test cases --- tests/appveyor_test_cases.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/appveyor_test_cases.py b/tests/appveyor_test_cases.py index e6c2de8..354593a 100644 --- a/tests/appveyor_test_cases.py +++ b/tests/appveyor_test_cases.py @@ -53,7 +53,7 @@ def test_getters(self): app.focus() self.assertEqual(app.getName(), "notepad.exe") self.assertTrue(app.isRunning()) - self.assertEqual(app.getWindow(), "test_cases(.py)? - Notepad") + self.assertRegex(app.getWindow(), "test_cases(.py)? - Notepad") self.assertNotEqual(app.getPID(), -1) region = app.window() self.assertIsInstance(region, lackey.Region) @@ -93,7 +93,7 @@ def test_launchers(self): lackey.wait(1) self.assertEqual(app.getName(), "notepad.exe") self.assertTrue(app.isRunning()) - self.assertEqual(app.getWindow(), "test_cases(.py)? - Notepad") + self.assertRegex(app.getWindow(), "test_cases(.py)? - Notepad") self.assertNotEqual(app.getPID(), -1) app.close() lackey.wait(0.9) From 3eed5c4d8de258860cca76aff1e7ed297c45378e Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Mon, 23 Oct 2017 16:19:33 -0400 Subject: [PATCH 36/37] Fixed ANSI change compatibility --- lackey/PlatformManagerWindows.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lackey/PlatformManagerWindows.py b/lackey/PlatformManagerWindows.py index be35fc9..f74a7c4 100644 --- a/lackey/PlatformManagerWindows.py +++ b/lackey/PlatformManagerWindows.py @@ -6,6 +6,7 @@ import numpy import ctypes import threading +from builtins import str # Python 2/3 compatibility try: import Tkinter as tk except ImportError: @@ -304,7 +305,7 @@ class BITMAPINFO(ctypes.Structure): ## Begin logic self._gdi32.CreateDCW.restype = ctypes.c_void_p - hdc = self._gdi32.CreateDCW(ctypes.c_wchar_p(unicode(device_name)), 0, 0, 0) # Convert to bytestring for c_wchar_p type + hdc = self._gdi32.CreateDCW(ctypes.c_wchar_p(str(device_name)), 0, 0, 0) # Convert to bytestring for c_wchar_p type if hdc == 0: raise ValueError("Empty hdc provided") From 692cc98eec0d157604de0904ed22efbef4976170 Mon Sep 17 00:00:00 2001 From: glitchassassin Date: Mon, 23 Oct 2017 16:36:22 -0400 Subject: [PATCH 37/37] Fixed compatibility --- lackey/PlatformManagerWindows.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lackey/PlatformManagerWindows.py b/lackey/PlatformManagerWindows.py index f74a7c4..4be30bf 100644 --- a/lackey/PlatformManagerWindows.py +++ b/lackey/PlatformManagerWindows.py @@ -6,7 +6,6 @@ import numpy import ctypes import threading -from builtins import str # Python 2/3 compatibility try: import Tkinter as tk except ImportError: @@ -21,6 +20,10 @@ basestring except NameError: basestring = str +try: + unicode +except: + unicode = str class PlatformManagerWindows(object): """ Abstracts Windows-specific OS-level features """