diff --git a/appveyor.yml b/appveyor.yml index d559a08..104017b 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,10 +1,17 @@ +branches: + only: + - master + environment: global: # SDK v7.0 MSVC Express 2008's SetEnv.cmd script will fail if the # /E:ON and /V:ON options are not enabled in the batch script intepreter # See: http://stackoverflow.com/a/13751649/163740 CMD_IN_ENV: "cmd /E:ON /V:ON /C .\\appveyor\\run_with_env.cmd" - + USER: + secure: rZM77hY3FVJKkbN0ZxbrjQ== + PASS: + secure: 1+JDFvadY94ojZGhbEeZ/G0of7zzFWwXaj4Mx0Th0Lo= matrix: # Python 2.7.12 is the latest version and is not pre-installed. @@ -59,6 +66,6 @@ artifacts: # Archive the generated packages in the ci.appveyor.com build report. - path: dist\* -#on_success: -# - TODO: upload the content of dist/*.whl to a public wheelhouse -# \ No newline at end of file +deploy_script: + # Deploy the generated wheel to PyPi + - if "%APPVEYOR_REPO_TAG%"=="true" (python -m twine upload -u %USER% -p %PASS% --skip-existing dist/Lackey*.whl) else (echo "Tag not set, deployment skipped.") diff --git a/lackey/App.py b/lackey/App.py new file mode 100644 index 0000000..85cfed1 --- /dev/null +++ b/lackey/App.py @@ -0,0 +1,243 @@ +""" Abstracts the capturing and interfacing of applications """ +import os +import re +import time +import platform +import subprocess + +from .RegionMatching import Region +from .Settings import Debug +from .PlatformManagerWindows import PlatformManagerWindows +from .Exceptions import FindFailed + +if platform.system() == "Windows": + PlatformManager = PlatformManagerWindows() # No other input managers built yet +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.") + +# Python 3 compatibility +try: + basestring +except NameError: + basestring = str + +class App(object): + """ Allows apps to be selected by title, PID, or by starting an + application directly. Can address individual windows tied to an + app. + + For more information, see [Sikuli's App documentation](http://sikulix-2014.readthedocs.io/en/latest/appclass.html#App) + """ + def __init__(self, identifier=None): + self._pid = None + self._search = identifier + self._title = "" + self._exec = "" + self._params = "" + self._process = None + self._defaultScanRate = 0.1 + self.proc = None + + # Replace class methods with instance methods + self.focus = self._focus_instance + self.close = self._close_instance + self.open = self._open_instance + + # Process `identifier` + if isinstance(identifier, int): + # `identifier` is a PID + Debug.log(3, "Creating App by PID ({})".format(identifier)) + self._pid = identifier + elif isinstance(identifier, basestring): + # `identifier` is either part of a window title + # or a command line to execute. If it starts with a "+", + # launch it immediately. Otherwise, store it until open() is called. + Debug.log(3, "Creating App by string ({})".format(identifier)) + launchNow = False + if identifier.startswith("+"): + # Should launch immediately - strip the `+` sign and continue + launchNow = True + identifier = identifier[1:] + # Check if `identifier` is an executable commmand + # Possible formats: + # Case 1: notepad.exe C:\sample.txt + # Case 2: "C:\Program Files\someprogram.exe" -flag + + # Extract hypothetical executable name + if identifier.startswith('"'): + executable = identifier[1:].split('"')[0] + params = identifier[len(executable)+2:].split(" ") if len(identifier) > len(executable) + 2 else [] + else: + executable = identifier.split(" ")[0] + params = identifier[len(executable)+1:].split(" ") if len(identifier) > len(executable) + 1 else [] + + # Check if hypothetical executable exists + if self._which(executable) is not None: + # Found the referenced executable + self._exec = executable + self._params = params + # If the command was keyed to execute immediately, do so. + if launchNow: + self.open() + else: + # No executable found - treat as a title instead. Try to capture window. + self._title = identifier + self.open + else: + self._pid = -1 # Unrecognized identifier, setting to empty app + + self._pid = self.getPID() # Confirm PID is an active process (sets to -1 otherwise) + + def _which(self, program): + """ Private method to check if an executable exists + + Shamelessly stolen from http://stackoverflow.com/questions/377017/test-if-executable-exists-in-python + """ + def is_exe(fpath): + return os.path.isfile(fpath) and os.access(fpath, os.X_OK) + + fpath, fname = os.path.split(program) + if fpath: + if is_exe(program): + return program + else: + for path in os.environ["PATH"].split(os.pathsep): + path = path.strip('"') + exe_file = os.path.join(path, program) + if is_exe(exe_file): + return exe_file + return None + + @classmethod + def pause(cls, waitTime): + time.sleep(waitTime) + + @classmethod + def focus(cls, appName): + """ Searches for exact text, case insensitive, anywhere in the window title. + + Brings the matching window to the foreground. + + As a class method, accessible as `App.focus(appName)`. As an instance method, + accessible as `App(appName).focus()`. + """ + app = cls(appName) + return app.focus() + def _focus_instance(self): + """ In instances, the ``focus()`` classmethod is replaced with this instance method. """ + if self._title: + Debug.log(3, "Focusing app with title like ({})".format(self._title)) + PlatformManager.focusWindow(PlatformManager.getWindowByTitle(re.escape(self._title))) + if self.getPID() == -1: + self.open() + elif self._pid and self._pid != -1: + Debug.log(3, "Focusing app with pid ({})".format(self._pid)) + PlatformManager.focusWindow(PlatformManager.getWindowByPID(self._pid)) + return self + + @classmethod + def close(cls, appName): + """ Closes the process associated with the specified app. + + As a class method, accessible as `App.class(appName)`. + As an instance method, accessible as `App(appName).close()`. + """ + return cls(appName).close() + def _close_instance(self): + if self._process: + self._process.terminate() + elif self.getPID() != -1: + PlatformManager.killProcess(self.getPID()) + + @classmethod + def open(self, executable): + """ Runs the specified command and returns an App linked to the generated PID. + + As a class method, accessible as `App.open(executable_path)`. + As an instance method, accessible as `App(executable_path).open()`. + """ + return App(executable).open() + def _open_instance(self, waitTime=0): + if self._exec != "": + # Open from an executable + parameters + self._process = subprocess.Popen([self._exec] + self._params, shell=False) + self._pid = self._process.pid + elif self._title != "": + # Capture an existing window that matches self._title + self._pid = PlatformManager.getWindowPID( + PlatformManager.getWindowByTitle( + re.escape(self._title))) + time.sleep(waitTime) + return self + + @classmethod + def focusedWindow(cls): + """ Returns a Region corresponding to whatever window is in the foreground """ + x, y, w, h = PlatformManager.getWindowRect(PlatformManager.getForegroundWindow()) + return Region(x, y, w, h) + + def getWindow(self): + """ Returns the title of the main window of the currently open app. + + Returns an empty string if no match could be found. + """ + if self.getPID() != -1: + return PlatformManager.getWindowTitle(PlatformManager.getWindowByPID(self.getPID())) + else: + return "" + def getName(self): + """ Returns the short name of the app as shown in the process list """ + return PlatformManager.getProcessName(self.getPID()) + def getPID(self): + """ Returns the PID for the associated app + (or -1, if no app is associated or the app is not running) + """ + if self._pid is not None: + if not PlatformManager.isPIDValid(self._pid): + self._pid = -1 + return self._pid + return -1 + + def hasWindow(self): + """ Returns True if the process has a window associated, False otherwise """ + return PlatformManager.getWindowByPID(self.getPID()) is not None + + def window(self, windowNum=0): + """ Returns the region corresponding to the specified window of the app. + + Defaults to the first window found for the corresponding PID. + """ + if self._pid == -1: + raise FindFailed("Window not found for app \"{}\"".format(self)) + x,y,w,h = PlatformManager.getWindowRect(PlatformManager.getWindowByPID(self._pid, windowNum)) + return Region(x,y,w,h).clipRegionToScreen() + + def setUsing(self, params): + self._params = params.split(" ") + + def __repr__(self): + """ Returns a string representation of the app """ + return "[{pid}:{executable} ({windowtitle})] {searchtext}".format(pid=self._pid, executable=self.getName(), windowtitle=self.getWindow(), searchtext=self._search) + + def isRunning(self, waitTime=0): + """ If PID isn't set yet, checks if there is a window with the specified title. """ + waitUntil = time.time() + waitTime + while True: + if self.getPID() > 0: + return True + else: + self._pid = PlatformManager.getWindowPID(PlatformManager.getWindowByTitle(re.escape(self._title))) + + # Check if we've waited long enough + if time.time() > waitUntil: + break + else: + time.sleep(self._defaultScanRate) + return self.getPID() > 0 + + @classmethod + def getClipboard(cls): + """ Gets the contents of the clipboard (as classmethod) """ + return PlatformManager.getClipboard() diff --git a/lackey/InputEmulation.py b/lackey/InputEmulation.py new file mode 100644 index 0000000..7718aca --- /dev/null +++ b/lackey/InputEmulation.py @@ -0,0 +1,351 @@ +""" +Interfaces with ``keyboard`` to provide mid-level input emulation routines +""" +import time + +import keyboard +from keyboard import mouse +from .Location import Location + +# Python 3 compatibility +try: + basestring +except NameError: + basestring = str + +class Mouse(object): + """ Mid-level mouse routines. """ + def __init__(self): + self._defaultScanRate = 0.01 + + # Class constants + WHEEL_DOWN = 0 + WHEEL_UP = 1 + LEFT = mouse.LEFT + MIDDLE = mouse.MIDDLE + RIGHT = mouse.RIGHT + + def move(self, location): + """ Moves cursor to specified ``Location`` """ + mouse.move(location.x, location.y) + + def getPos(self): + """ Gets ``Location`` of cursor """ + return Location(*mouse.get_position()) + + @classmethod + def at(cls): + """ Gets ``Location`` of cursor (as class method) """ + return Location(*mouse.get_position()) + + def moveSpeed(self, location, seconds=0.3): + """ Moves cursor to specified ``Location`` over ``seconds``. + + If ``seconds`` is 0, moves the cursor immediately. Used for smooth + somewhat-human-like motion. + """ + original_location = mouse.get_position() + mouse.move(location.x, location.y, duration=seconds) + if mouse.get_position() == original_location and original_location != location.getTuple(): + raise IOError(""" + Unable to move mouse cursor. This may happen if you're trying to automate a + program running as Administrator with a script running as a non-elevated user. + """) + + def click(self, button=mouse.LEFT): + """ Clicks the specified mouse button. + + Use Mouse.LEFT, Mouse.MIDDLE, Mouse.RIGHT + """ + mouse.click(button) + def buttonDown(self, button=mouse.LEFT): + """ Holds down the specified mouse button. + + Use Mouse.LEFT, Mouse.MIDDLE, Mouse.RIGHT + """ + mouse.press(button) + def buttonUp(self, button=mouse.LEFT): + """ Releases the specified mouse button. + + Use Mouse.LEFT, Mouse.MIDDLE, Mouse.RIGHT + """ + mouse.release(button) + def wheel(self, direction, steps): + """ Clicks the wheel the specified number of steps in the given direction. + + Use Mouse.WHEEL_DOWN, Mouse.WHEEL_UP + """ + if direction == 1: + wheel_moved = steps + elif direction == 0: + wheel_moved = -1*steps + else: + raise ValueError("Expected direction to be 1 or 0") + return mouse.wheel(wheel_moved) + +class Keyboard(object): + """ Mid-level keyboard routines. Interfaces with ``PlatformManager`` """ + def __init__(self): + # 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 keyDown(self, keys): + """ Accepts a string of keys (including special keys wrapped in brackets or provided + by the Key or KeyModifier classes). Holds down all of them. """ + if not isinstance(keys, basestring): + raise TypeError("keyDown expected keys to be a string") + in_special_code = False + special_code = "" + for i in range(0, len(keys)): + if keys[i] == "{": + in_special_code = True + elif in_special_code and (keys[i] == "}" or keys[i] == " " or i == len(keys)-1): + # End of special code (or it wasn't a special code after all) + in_special_code = False + if special_code in self._SPECIAL_KEYCODES.keys(): + # Found a special code + keyboard.press(self._SPECIAL_KEYCODES[special_code]) + else: + # Wasn't a special code, just treat it as keystrokes + self.keyDown("{") + # Press the rest of the keys normally + self.keyDown(special_code) + self.keyDown(keys[i]) + special_code = "" + elif in_special_code: + special_code += keys[i] + elif keys[i] in self._REGULAR_KEYCODES.keys(): + keyboard.press(keys[i]) + elif keys[i] in self._UPPERCASE_KEYCODES.keys(): + keyboard.press(self._SPECIAL_KEYCODES["SHIFT"]) + keyboard.press(self._UPPERCASE_KEYCODES[keys[i]]) + def keyUp(self, keys): + """ Accepts a string of keys (including special keys wrapped in brackets or provided + by the Key or KeyModifier classes). Releases any that are held down. """ + if not isinstance(keys, basestring): + raise TypeError("keyUp expected keys to be a string") + in_special_code = False + special_code = "" + for i in range(0, len(keys)): + if keys[i] == "{": + in_special_code = True + elif in_special_code and (keys[i] == "}" or keys[i] == " " or i == len(keys)-1): + # End of special code (or it wasn't a special code after all) + in_special_code = False + if special_code in self._SPECIAL_KEYCODES.keys(): + # Found a special code + keyboard.release(self._SPECIAL_KEYCODES[special_code]) + else: + # Wasn't a special code, just treat it as keystrokes + self.keyUp("{") + # Release the rest of the keys normally + self.keyUp(special_code) + self.keyUp(keys[i]) + special_code = "" + elif in_special_code: + special_code += keys[i] + elif keys[i] in self._REGULAR_KEYCODES.keys(): + keyboard.release(self._REGULAR_KEYCODES[keys[i]]) + elif keys[i] in self._UPPERCASE_KEYCODES.keys(): + keyboard.release(self._SPECIAL_KEYCODES["SHIFT"]) + keyboard.release(self._UPPERCASE_KEYCODES[keys[i]]) + def type(self, text, delay=0.1): + """ Translates a string into a series of keystrokes. + + Respects Sikuli special codes, like "{ENTER}". + """ + in_special_code = False + special_code = "" + modifier_held = False + modifier_stuck = False + modifier_codes = [] + + for i in range(0, len(text)): + if text[i] == "{": + in_special_code = True + elif in_special_code and (text[i] == "}" or text[i] == " " or i == len(text)-1): + in_special_code = False + if special_code in self._SPECIAL_KEYCODES.keys(): + # Found a special code + keyboard.press_and_release(self._SPECIAL_KEYCODES[special_code]) + else: + # Wasn't a special code, just treat it as keystrokes + keyboard.press(self._SPECIAL_KEYCODES["SHIFT"]) + keyboard.press_and_release(self._UPPERCASE_KEYCODES["{"]) + keyboard.release(self._SPECIAL_KEYCODES["SHIFT"]) + # Release the rest of the keys normally + self.type(special_code) + self.type(text[i]) + elif in_special_code: + special_code += text[i] + elif text[i] in self._REGULAR_KEYCODES.keys(): + keyboard.press(self._REGULAR_KEYCODES[text[i]]) + keyboard.release(self._REGULAR_KEYCODES[text[i]]) + elif text[i] in self._UPPERCASE_KEYCODES.keys(): + keyboard.press(self._SPECIAL_KEYCODES["SHIFT"]) + keyboard.press_and_release(self._UPPERCASE_KEYCODES[text[i]]) + keyboard.release(self._SPECIAL_KEYCODES["SHIFT"]) + if delay: + time.sleep(delay) + diff --git a/lackey/KeyCodes.py b/lackey/KeyCodes.py index 5b5bac6..3e4ec44 100644 --- a/lackey/KeyCodes.py +++ b/lackey/KeyCodes.py @@ -33,15 +33,15 @@ class Key(): RIGHT = "{RIGHT}" DOWN = "{DOWN}" UP = "{UP}" - PAGE_DOWN = "{PGDN}" - PAGE_UP = "{PGUP}" + PAGE_DOWN = "{PAGE_DOWN}" + PAGE_UP = "{PAGE_UP}" TAB = "{TAB}" CAPS_LOCK = "{CAPS_LOCK}" NUM_LOCK = "{NUM_LOCK}" SCROLL_LOCK = "{SCROLL_LOCK}" INSERT = "{INSERT}" SPACE = "{SPACE}" - PRINTSCREEN = "{PRTSC}" + PRINTSCREEN = "{PRINTSCREEN}" ALT = "{ALT}" CMD = "{CMD}" CONTROL = "{CTRL}" @@ -49,22 +49,27 @@ class Key(): SHIFT = "{SHIFT}" WIN = "{WIN}" PAUSE = "{PAUSE}" - NUM_0 = "{NUM_0}" - NUM_1 = "{NUM_1}" - NUM_2 = "{NUM_2}" - NUM_3 = "{NUM_3}" - NUM_4 = "{NUM_4}" - NUM_5 = "{NUM_5}" - NUM_6 = "{NUM_6}" - NUM_7 = "{NUM_7}" - NUM_8 = "{NUM_8}" - NUM_9 = "{NUM_9}" + NUM0 = "{NUM0}" + NUM1 = "{NUM1}" + NUM2 = "{NUM2}" + NUM3 = "{NUM3}" + NUM4 = "{NUM4}" + NUM5 = "{NUM5}" + NUM6 = "{NUM6}" + NUM7 = "{NUM7}" + NUM8 = "{NUM8}" + NUM9 = "{NUM9}" + SEPARATOR = "{SEPARATOR}" + ADD = "{ADD}" + MINUS = "{MINUS}" + MULTIPLY = "{MULTIPLY}" + DIVIDE = "{DIVIDE}" class KeyModifier(): """ Key modifiers precede either a single key [e.g., ^v] or a set of characters within parentheses [e.g., +(hello)] """ CTRL = "{CTRL}" SHIFT = "{SHIFT}" ALT = "{ALT}" - META = "{WIN}" - CMD = "{WIN}" + META = "{META}" + CMD = "{CMD}" WIN = "{WIN}" \ No newline at end of file diff --git a/lackey/Location.py b/lackey/Location.py new file mode 100644 index 0000000..a028ba7 --- /dev/null +++ b/lackey/Location.py @@ -0,0 +1,43 @@ +class Location(object): + """ Basic 2D point object """ + def __init__(self, x, y): + self.setLocation(x, y) + + def getX(self): + """ Returns the X-component of the location """ + return self.x + def getY(self): + """ Returns the Y-component of the location """ + return self.y + + def setLocation(self, x, y): + """Set the location of this object to the specified coordinates.""" + self.x = int(x) + self.y = int(y) + + def offset(self, dx, dy): + """Get a new location which is dx and dy pixels away horizontally and vertically + from the current location. + """ + return Location(self.x+dx, self.y+dy) + + def above(self, dy): + """Get a new location dy pixels vertically above the current location.""" + return Location(self.x, self.y-dy) + def below(self, dy): + """Get a new location dy pixels vertically below the current location.""" + return Location(self.x, self.y+dy) + def left(self, dx): + """Get a new location dx pixels horizontally to the left of the current location.""" + return Location(self.x-dx, self.y) + def right(self, dx): + """Get a new location dx pixels horizontally to the right of the current location.""" + return Location(self.x+dx, self.y) + + def getTuple(self): + """ Returns coordinates as a tuple (for some PlatformManager methods) """ + return (self.x, self.y) + + def __repr__(self): + return "(Location object at ({},{}))".format(self.x, self.y) + diff --git a/lackey/PlatformManagerWindows.py b/lackey/PlatformManagerWindows.py index 4e01b9e..671cce6 100644 --- a/lackey/PlatformManagerWindows.py +++ b/lackey/PlatformManagerWindows.py @@ -10,10 +10,10 @@ except ImportError: import tkinter as tk from ctypes import wintypes -import keyboard -from keyboard import mouse from PIL import Image, ImageTk, ImageOps + from .Settings import Debug +from .InputEmulation import Keyboard # Python 3 compatibility try: @@ -36,6 +36,9 @@ def __init__(self): 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", @@ -49,8 +52,8 @@ def __init__(self): "CAPS_LOCK": "caps lock", "ESC": "esc", "SPACE": "spacebar", - "PGUP": "page up", - "PGDN": "page down", + "PAGE_UP": "page up", + "PAGE_DOWN": "page down", "END": "end", "HOME": "home", "LEFT": "left arrow", @@ -59,20 +62,28 @@ def __init__(self): "DOWN": "down arrow", "SELECT": "select", "PRINT": "print", - "PRINT_SCREEN": "print screen", + "PRINTSCREEN": "print screen", "INSERT": "ins", "DELETE": "del", - "WIN": "left windows", - "NUM_0": "keypad 0", - "NUM_1": "keypad 1", - "NUM_2": "keypad 2", - "NUM_3": "keypad 3", - "NUM_4": "keypad 4", - "NUM_5": "keypad 5", - "NUM_6": "keypad 6", - "NUM_7": "keypad 7", - "NUM_8": "keypad 8", - "NUM_9": "keypad 9", + "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", @@ -199,135 +210,7 @@ def _check_count(self, result, func, args): raise ctypes.WinError(ctypes.get_last_error()) return args - ## Keyboard input methods ## - def pressKey(self, text): - """ Accepts a string of keys in typeKeys format (see below). Holds down all of them. """ - if not isinstance(text, basestring): - raise TypeError("pressKey expected text to be a string") - in_special_code = False - special_code = "" - for i in range(0, len(text)): - if text[i] == "{": - in_special_code = True - elif in_special_code and (text[i] == "}" or text[i] == " " or i == len(text)-1): - # End of special code (or it wasn't a special code after all) - in_special_code = False - if special_code in self._SPECIAL_KEYCODES.keys(): - # Found a special code - keyboard.press(self._SPECIAL_KEYCODES[special_code]) - else: - # Wasn't a special code, just treat it as keystrokes - self.pressKey("{") - # Press the rest of the keys normally - self.pressKey(special_code) - self.pressKey(text[i]) - special_code = "" - elif in_special_code: - special_code += text[i] - elif text[i] in self._REGULAR_KEYCODES.keys(): - keyboard.press(text[i]) - elif text[i] in self._UPPERCASE_KEYCODES.keys(): - keyboard.press(self._SPECIAL_KEYCODES["SHIFT"]) - keyboard.press(self._UPPERCASE_KEYCODES[text[i]]) - def releaseKey(self, text): - """ Accepts a string of keys in typeKeys format (see below). Releases all of them. """ - - in_special_code = False - special_code = "" - for i in range(0, len(text)): - if text[i] == "{": - in_special_code = True - elif in_special_code and (text[i] == "}" or text[i] == " " or i == len(text)-1): - # End of special code (or it wasn't a special code after all) - in_special_code = False - if special_code in self._SPECIAL_KEYCODES.keys(): - # Found a special code - keyboard.release(self._SPECIAL_KEYCODES[special_code]) - else: - # Wasn't a special code, just treat it as keystrokes - self.releaseKey("{") - # Release the rest of the keys normally - self.releaseKey(special_code) - self.releaseKey(text[i]) - special_code = "" - elif in_special_code: - special_code += text[i] - elif text[i] in self._REGULAR_KEYCODES.keys(): - keyboard.release(self._REGULAR_KEYCODES[text[i]]) - elif text[i] in self._UPPERCASE_KEYCODES.keys(): - keyboard.release(self._SPECIAL_KEYCODES["SHIFT"]) - keyboard.release(self._UPPERCASE_KEYCODES[text[i]]) - def typeKeys(self, text, delay=0.1): - """ Translates a string into a series of keystrokes. - - Respects Sikuli special codes, like "{ENTER}". Does not - use SendKeys-like modifiers. - """ - in_special_code = False - special_code = "" - modifier_held = False - modifier_stuck = False - modifier_codes = [] - - for i in range(0, len(text)): - if text[i] == "{": - in_special_code = True - elif in_special_code and (text[i] == "}" or text[i] == " " or i == len(text)-1): - in_special_code = False - if special_code in self._SPECIAL_KEYCODES.keys(): - # Found a special code - keyboard.press_and_release(self._SPECIAL_KEYCODES[special_code]) - else: - # Wasn't a special code, just treat it as keystrokes - keyboard.press(self._SPECIAL_KEYCODES["SHIFT"]) - keyboard.press_and_release(self._UPPERCASE_KEYCODES["{"]) - keyboard.release(self._SPECIAL_KEYCODES["SHIFT"]) - # Release the rest of the keys normally - return - self.typeKeys(special_code) - self.typeKeys(text[i]) - elif in_special_code: - special_code += text[i] - elif text[i] in self._REGULAR_KEYCODES.keys(): - keyboard.press(self._REGULAR_KEYCODES[text[i]]) - keyboard.release(self._REGULAR_KEYCODES[text[i]]) - elif text[i] in self._UPPERCASE_KEYCODES.keys(): - keyboard.press(self._SPECIAL_KEYCODES["SHIFT"]) - keyboard.press_and_release(self._UPPERCASE_KEYCODES[text[i]]) - keyboard.release(self._SPECIAL_KEYCODES["SHIFT"]) - if delay: - time.sleep(delay) - - ## Mouse input methods - - def setMousePos(self, location): - """ Accepts a tuple (x,y) and sets the mouse position accordingly """ - x, y = location - if self.isPointVisible(x, y): - mouse.move(x, y) - def getMousePos(self): - """ Returns the current mouse position as a tuple (x,y) - - Relative to origin of main screen top left (0,0). May be negative. - """ - return mouse.get_position() - def mouseButtonDown(self, button=0): - """ Translates the button (0=LEFT, 1=MIDDLE, 2=RIGHT) and sends a mousedown to the OS """ - button_code = [mouse.LEFT, mouse.MIDDLE, mouse.RIGHT][button] - mouse.press(button_code) - def mouseButtonUp(self, button=0): - """ Translates the button (0=LEFT, 1=MIDDLE, 2=RIGHT) and sends a mouseup to the OS """ - button_code = [mouse.LEFT, mouse.MIDDLE, mouse.RIGHT][button] - mouse.release(button_code) - def clickMouse(self, button=0): - """ Abstracts the clicking function - - Button codes are (0=LEFT, 1=MIDDLE, 2=RIGHT) and should be provided as constants - by the Mouse class - """ - button_code = [mouse.LEFT, mouse.MIDDLE, mouse.RIGHT][button] - mouse.click(button_code) - def mouseWheel(self, direction, steps): + """ Clicks the mouse wheel the specified number of steps in the given direction Valid directions are 0 (for down) and 1 (for up). These should be provided @@ -339,7 +222,7 @@ def mouseWheel(self, direction, steps): wheel_moved = -1*steps else: raise ValueError("Expected direction to be 1 or 0") - mouse._os_mouse.queue.put(WheelEvent(wheel_moved, time.time())) + mouse._os_mouse.wheel(wheel_moved) ## Screen functions @@ -654,14 +537,16 @@ def setClipboard(self, text): root.destroy() def osCopy(self): """ Triggers the OS "copy" keyboard shortcut """ - self.pressKey("{CTRL}") - self.typeKeys("c") - self.releaseKey("{CTRL}") + k = Keyboard() + k.keyDown("{CTRL}") + k.type("c") + k.keyUp("{CTRL}") def osPaste(self): """ Triggers the OS "paste" keyboard shortcut """ - self.pressKey("{CTRL}") - self.typeKeys("v") - self.releaseKey("{CTRL}") + k = Keyboard() + k.keyDown("{CTRL}") + k.type("v") + k.keyUp("{CTRL}") ## Window functions diff --git a/lackey/RegionMatching.py b/lackey/RegionMatching.py index 8cd94bb..2092cc1 100644 --- a/lackey/RegionMatching.py +++ b/lackey/RegionMatching.py @@ -15,6 +15,8 @@ from .PlatformManagerWindows import PlatformManagerWindows +from .InputEmulation import Mouse, Keyboard +from .Location import Location from .Exceptions import FindFailed from .Settings import Settings, Debug from .TemplateMatchers import PyramidTemplateMatcher as TemplateMatcher @@ -570,7 +572,7 @@ def click(self, target, modifiers=""): else: raise TypeError("click expected Pattern, String, Match, Region, or Location object") if modifiers != "": - PlatformManager.pressKey(modifiers) + Keyboard().keyDown(modifiers) mouse.moveSpeed(target_location, self._defaultMouseSpeed) time.sleep(0.1) # For responsiveness @@ -581,7 +583,7 @@ def click(self, target, modifiers=""): time.sleep(0.1) if modifiers != 0: - PlatformManager.releaseKey(modifiers) + Keyboard().keyUp(modifiers) Debug.history("Clicked at {}".format(target_location)) def doubleClick(self, target, modifiers=""): """ Moves the cursor to the target location and double-clicks the default mouse button. """ @@ -600,7 +602,7 @@ def doubleClick(self, target, modifiers=""): else: raise TypeError("doubleClick expected Pattern, String, Match, Region, or Location object") if modifiers != "": - PlatformManager.pressKey(modifiers) + Keyboard().keyDown(modifiers) mouse.moveSpeed(target_location, self._defaultMouseSpeed) time.sleep(0.1) @@ -616,7 +618,7 @@ def doubleClick(self, target, modifiers=""): time.sleep(0.1) if modifiers != 0: - PlatformManager.releaseKey(modifiers) + Keyboard().keyUp(modifiers) def rightClick(self, target, modifiers=""): """ Moves the cursor to the target location and clicks the right mouse button. """ target_location = None @@ -635,7 +637,7 @@ def rightClick(self, target, modifiers=""): raise TypeError("rightClick expected Pattern, String, Match, Region, or Location object") if modifiers != "": - PlatformManager.pressKey(modifiers) + Keyboard().keyDown(modifiers) mouse.moveSpeed(target_location, self._defaultMouseSpeed) time.sleep(0.1) @@ -646,7 +648,7 @@ def rightClick(self, target, modifiers=""): time.sleep(0.1) if modifiers != "": - PlatformManager.releaseKey(modifiers) + Keyboard().keyUp(modifiers) def hover(self, target): """ Moves the cursor to the target location """ @@ -722,14 +724,14 @@ def dragDrop(self, dragFrom, dragTo, modifiers=""): during the drag-drop operation. """ if modifiers != "": - PlatformManager.pressKey(modifiers) + Keyboard().keyDown(modifiers) self.drag(dragFrom) time.sleep(Settings.DelayBeforeDrag) self.dropAt(dragTo) if modifiers != "": - PlatformManager.releaseKey(modifiers) + Keyboard().keyUp(modifiers) def type(self, *args): """ Usage: type([PSMRL], text, [modifiers]) @@ -1098,356 +1100,3 @@ def selectRegion(self, text=""): """ Not yet implemented """ raise NotImplementedError() -class Location(object): - """ Basic 2D point object """ - def __init__(self, x, y): - self.setLocation(x, y) - - def getX(self): - """ Returns the X-component of the location """ - return self.x - def getY(self): - """ Returns the Y-component of the location """ - return self.y - - def setLocation(self, x, y): - """Set the location of this object to the specified coordinates.""" - self.x = int(x) - self.y = int(y) - - def offset(self, dx, dy): - """Get a new location which is dx and dy pixels away horizontally and vertically from the current location.""" - return Location(self.x+dx, self.y+dy) - - def above(self, dy): - """Get a new location dy pixels vertically above the current location.""" - return Location(self.x, self.y-dy) - def below(self, dy): - """Get a new location dy pixels vertically below the current location.""" - return Location(self.x, self.y+dy) - def left(self, dx): - """Get a new location dx pixels horizontally to the left of the current location.""" - return Location(self.x-dx, self.y) - def right(self, dx): - """Get a new location dx pixels horizontally to the right of the current location.""" - return Location(self.x+dx, self.y) - - def getTuple(self): - """ Returns coordinates as a tuple (for some PlatformManager methods) """ - return (self.x, self.y) - - def __repr__(self): - return "(Location object at ({},{}))".format(self.x, self.y) - -class Mouse(object): - """ Mid-level mouse routines. Interfaces with ``PlatformManager`` """ - def __init__(self): - self._defaultScanRate = 0.01 - - # Class constants - WHEEL_DOWN = 0 - WHEEL_UP = 1 - LEFT = 0 - MIDDLE = 1 - RIGHT = 2 - - def move(self, location): - """ Moves cursor to specified ``Location`` """ - - # Check if move point is outside a monitor rectangle - if not PlatformManager.isPointVisible(location.x, location.y): - # move point is outside a monitor rectangle. Snap to closest edge of primary monitor. - s_x, s_y, s_w, s_h = Screen(0).getBounds() - location.x = min(max(location.x, s_x), s_x+s_w) - location.y = min(max(location.y, s_y), s_y+s_h) - - PlatformManager.setMousePos(location.getTuple()) - - def getPos(self): - """ Gets ``Location`` of cursor """ - x, y = PlatformManager.getMousePos() - return Location(x, y) - - def moveSpeed(self, location, seconds=0.3): - """ Moves cursor to specified ``Location`` over ``seconds``. - - If ``seconds`` is 0, moves the cursor immediately. Used for smooth - somewhat-human-like motion. - """ - original_location = PlatformManager.getMousePos() - if seconds == 0: - # If the mouse isn't on the main screen, snap to point automatically instead of - # trying to track a path back - self.move(location) - return - - # Check if move point is outside a monitor rectangle - if not PlatformManager.isPointVisible(location.x, location.y): - # move point is outside a monitor rectangle. Snap to closest edge of primary monitor. - s_x, s_y, s_w, s_h = Screen(0).getBounds() - location.x = min(max(location.x, s_x), s_x+s_w) - location.y = min(max(location.y, s_y), s_y+s_h) - - frames = int(seconds / self._defaultScanRate) - while frames > 0: - mouse_pos = self.getPos() - deltax = int(round(float(location.x - mouse_pos.x) / frames)) - deltay = int(round(float(location.y - mouse_pos.y) / frames)) - self.move(Location(mouse_pos.x + deltax, mouse_pos.y + deltay)) - frames -= 1 - time.sleep(self._defaultScanRate) - if PlatformManager.getMousePos() == original_location and original_location != location.getTuple(): - raise IOError("Unable to move mouse cursor. This may happen if you're trying to automate a program running as Administrator with a script running as a non-elevated user.") - - def click(self, button=0): - """ Clicks the specified mouse button. - - Use Mouse.LEFT, Mouse.MIDDLE, Mouse.RIGHT - """ - PlatformManager.clickMouse(button) - def buttonDown(self, button=0): - """ Holds down the specified mouse button. - - Use Mouse.LEFT, Mouse.MIDDLE, Mouse.RIGHT - """ - PlatformManager.mouseButtonDown(button) - def buttonUp(self, button=0): - """ Releases the specified mouse button. - - Use Mouse.LEFT, Mouse.MIDDLE, Mouse.RIGHT - """ - PlatformManager.mouseButtonUp(button) - def wheel(self, direction, steps): - """ Clicks the wheel the specified number of steps in the given direction. - - Use Mouse.WHEEL_DOWN, Mouse.WHEEL_UP - """ - return PlatformManager.mouseWheel(direction, steps) - -class Keyboard(object): - """ Mid-level keyboard routines. Interfaces with ``PlatformManager`` """ - def __init__(self): - pass - - def keyDown(self, keys): - """ Holds down the specified keys """ - return PlatformManager.pressKey(keys) - def keyUp(self, keys): - """ Releases the specified keys """ - return PlatformManager.releaseKey(keys) - def type(self, text, delay=0.1): - """ Types ``text`` with ``delay`` seconds between keypresses """ - return PlatformManager.typeKeys(text, delay) - -class App(object): - """ Allows apps to be selected by title, PID, or by starting an - application directly. Can address individual windows tied to an - app. - - For more information, see [Sikuli's App documentation](http://sikulix-2014.readthedocs.io/en/latest/appclass.html#App) - """ - def __init__(self, identifier=None): - self._pid = None - self._search = identifier - self._title = "" - self._exec = "" - self._params = "" - self._process = None - self._defaultScanRate = 0.1 - self.proc = None - - # Replace class methods with instance methods - self.focus = self._focus_instance - self.close = self._close_instance - self.open = self._open_instance - - # Process `identifier` - if isinstance(identifier, int): - # `identifier` is a PID - Debug.log(3, "Creating App by PID ({})".format(identifier)) - self._pid = identifier - elif isinstance(identifier, basestring): - # `identifier` is either part of a window title - # or a command line to execute. If it starts with a "+", - # launch it immediately. Otherwise, store it until open() is called. - Debug.log(3, "Creating App by string ({})".format(identifier)) - launchNow = False - if identifier.startswith("+"): - # Should launch immediately - strip the `+` sign and continue - launchNow = True - identifier = identifier[1:] - # Check if `identifier` is an executable commmand - # Possible formats: - # Case 1: notepad.exe C:\sample.txt - # Case 2: "C:\Program Files\someprogram.exe" -flag - - # Extract hypothetical executable name - if identifier.startswith('"'): - executable = identifier[1:].split('"')[0] - params = identifier[len(executable)+2:].split(" ") if len(identifier) > len(executable) + 2 else [] - else: - executable = identifier.split(" ")[0] - params = identifier[len(executable)+1:].split(" ") if len(identifier) > len(executable) + 1 else [] - - # Check if hypothetical executable exists - if self._which(executable) is not None: - # Found the referenced executable - self._exec = executable - self._params = params - # If the command was keyed to execute immediately, do so. - if launchNow: - self.open() - else: - # No executable found - treat as a title instead. Try to capture window. - self._title = identifier - self.open - else: - self._pid = -1 # Unrecognized identifier, setting to empty app - - self._pid = self.getPID() # Confirm PID is an active process (sets to -1 otherwise) - - def _which(self, program): - """ Private method to check if an executable exists - - Shamelessly stolen from http://stackoverflow.com/questions/377017/test-if-executable-exists-in-python - """ - def is_exe(fpath): - return os.path.isfile(fpath) and os.access(fpath, os.X_OK) - - fpath, fname = os.path.split(program) - if fpath: - if is_exe(program): - return program - else: - for path in os.environ["PATH"].split(os.pathsep): - path = path.strip('"') - exe_file = os.path.join(path, program) - if is_exe(exe_file): - return exe_file - return None - - @classmethod - def pause(cls, waitTime): - time.sleep(waitTime) - - @classmethod - def focus(cls, appName): - """ Searches for exact text, case insensitive, anywhere in the window title. - - Brings the matching window to the foreground. - - As a class method, accessible as `App.focus(appName)`. As an instance method, - accessible as `App(appName).focus()`. - """ - app = cls(appName) - return app.focus() - def _focus_instance(self): - """ In instances, the ``focus()`` classmethod is replaced with this instance method. """ - if self._title: - Debug.log(3, "Focusing app with title like ({})".format(self._title)) - PlatformManager.focusWindow(PlatformManager.getWindowByTitle(re.escape(self._title))) - if self.getPID() == -1: - self.open() - elif self._pid and self._pid != -1: - Debug.log(3, "Focusing app with pid ({})".format(self._pid)) - PlatformManager.focusWindow(PlatformManager.getWindowByPID(self._pid)) - return self - - @classmethod - def close(cls, appName): - """ Closes the process associated with the specified app. - - As a class method, accessible as `App.class(appName)`. - As an instance method, accessible as `App(appName).close()`. - """ - return cls(appName).close() - def _close_instance(self): - if self._process: - self._process.terminate() - elif self.getPID() != -1: - PlatformManager.killProcess(self.getPID()) - - @classmethod - def open(self, executable): - """ Runs the specified command and returns an App linked to the generated PID. - - As a class method, accessible as `App.open(executable_path)`. - As an instance method, accessible as `App(executable_path).open()`. - """ - return App(executable).open() - def _open_instance(self, waitTime=0): - if self._exec != "": - # Open from an executable + parameters - self._process = subprocess.Popen([self._exec] + self._params, shell=False) - self._pid = self._process.pid - elif self._title != "": - # Capture an existing window that matches self._title - self._pid = PlatformManager.getWindowPID( - PlatformManager.getWindowByTitle( - re.escape(self._title))) - time.sleep(waitTime) - return self - - @classmethod - def focusedWindow(cls): - """ Returns a Region corresponding to whatever window is in the foreground """ - x, y, w, h = PlatformManager.getWindowRect(PlatformManager.getForegroundWindow()) - return Region(x,y,w,h) - - def getWindow(self): - """ Returns the title of the main window of the currently open app. - - Returns an empty string if no match could be found. - """ - if self.getPID() != -1: - return PlatformManager.getWindowTitle(PlatformManager.getWindowByPID(self.getPID())) - else: - return "" - def getName(self): - """ Returns the short name of the app as shown in the process list """ - return PlatformManager.getProcessName(self.getPID()) - def getPID(self): - """ Returns the PID for the associated app (or -1, if no app is associated or the app is not running) """ - if self._pid is not None: - if not PlatformManager.isPIDValid(self._pid): - self._pid = -1 - return self._pid - return -1 - - def hasWindow(self): - """ Returns True if the process has a window associated, False otherwise """ - return PlatformManager.getWindowByPID(self.getPID()) is not None - - def window(self, windowNum=0): - """ Returns the region corresponding to the specified window of the app. - - Defaults to the first window found for the corresponding PID. - """ - if self._pid == -1: - raise FindFailed("Window not found for app \"{}\"".format(self)) - x,y,w,h = PlatformManager.getWindowRect(PlatformManager.getWindowByPID(self._pid, windowNum)) - return Region(x,y,w,h).clipRegionToScreen() - - def setUsing(self, params): - self._params = params.split(" ") - - def __repr__(self): - """ Returns a string representation of the app """ - return "[{pid}:{executable} ({windowtitle})] {searchtext}".format(pid=self._pid, executable=self.getName(), windowtitle=self.getWindow(), searchtext=self._search) - - def isRunning(self, waitTime=0): - """ If PID isn't set yet, checks if there is a window with the specified title. """ - waitUntil = time.time() + waitTime - while True: - if self.getPID() > 0: - return True - else: - self._pid = PlatformManager.getWindowPID(PlatformManager.getWindowByTitle(re.escape(self._title))) - - # Check if we've waited long enough - if time.time() > waitUntil: - break - else: - time.sleep(self._defaultScanRate) - return self.getPID() > 0 - diff --git a/lackey/Settings.py b/lackey/Settings.py index 0a92ee9..cb9328a 100644 --- a/lackey/Settings.py +++ b/lackey/Settings.py @@ -3,6 +3,8 @@ import os import __main__ +from ._version import __version__, __sikuli_version__ + class DebugMaster(object): """ Used to create the global Debug object """ _log_file = None @@ -166,5 +168,11 @@ class SettingsMaster(object): ## Popup settings PopupLocation = None + # Environment methods + + def getSikuliVersion(self): + return "Lackey {} (compatible with SikuliX {})".format(__version__, __sikuli_version__) + + Debug = DebugMaster() Settings = SettingsMaster() diff --git a/lackey/TemplateMatchers.py b/lackey/TemplateMatchers.py index fa02e7d..1d38cf7 100644 --- a/lackey/TemplateMatchers.py +++ b/lackey/TemplateMatchers.py @@ -69,9 +69,9 @@ def findAllMatches(self, needle, similarity): return positions class PyramidTemplateMatcher(object): - """ Python wrapper for OpenCV's TemplateMatcher + """ Python wrapper for OpenCV's TemplateMatcher - Uses a pyramid model to optimize matching speed + Uses a pyramid model to optimize matching speed """ def __init__(self, haystack): self.haystack = cv2.cvtColor(haystack, cv2.COLOR_BGR2GRAY) # Convert to grayscale @@ -86,17 +86,28 @@ def findBestMatch(self, needle, similarity): with enough similarity, not the **best** result.* """ needle = cv2.cvtColor(needle, cv2.COLOR_BGR2GRAY) # Convert to grayscale + haystack = self.haystack + # Check if haystack or needle are a solid color - if so, switch to SQDIFF_NORMED + + if self._is_solid_color(needle): + print("Solid color, using SQDIFF") + method = cv2.TM_SQDIFF_NORMED + if self._is_solid_black(needle): + print("Inverting images") + # Invert needle & haystack before matching + needle = numpy.invert(needle) + haystack = numpy.invert(haystack) + else: + #print("Not Solid color, using CCOEFF") + method = cv2.TM_CCOEFF_NORMED levels = 3 needle_pyramid = self._build_pyramid(needle, levels) # Needle will be smaller than haystack, so may not be able to create # ``levels`` smaller versions of itself. If not, create only as many # levels for ``haystack`` as we could for ``needle``. - haystack_pyramid = self._build_pyramid(self.haystack, min(levels, len(needle_pyramid))) + haystack_pyramid = self._build_pyramid(haystack, min(levels, len(needle_pyramid))) roi_mask = None - method = cv2.TM_CCOEFF_NORMED - - # Run through each level in the pyramid, refining found ROIs for level in range(len(haystack_pyramid)): @@ -200,6 +211,8 @@ def findBestMatch(self, needle, similarity): return None # There was a match! + if method == cv2.TM_SQDIFF_NORMED: + confidence = 1 - confidence # Invert confidence if we used the SQDIFF method return (position, confidence) def findAllMatches(self, needle, similarity): @@ -207,115 +220,24 @@ def findAllMatches(self, needle, similarity): Pyramid implementation unashamedly stolen from https://github.com/stb-tester/stb-tester """ - needle = cv2.cvtColor(needle, cv2.COLOR_BGR2GRAY) # Convert to grayscale - - levels = 3 - needle_pyramid = self._build_pyramid(needle, levels) - # Needle will be smaller than haystack, so may not be able to create ``levels`` smaller - # versions of itself. If not, create only as many levels for haystack as we could for - # needle. - haystack_pyramid = self._build_pyramid(self.haystack, min(levels, len(needle_pyramid))) - roi_mask = None - method = cv2.TM_CCOEFF_NORMED positions = [] - - # Run through each level in the pyramid, refining found ROIs - for level in range(len(haystack_pyramid)): - # Populate the heatmap with ones or zeroes depending on the appropriate method - lvl_haystack = haystack_pyramid[level] - lvl_needle = needle_pyramid[level] - matches_heatmap = ( - (numpy.ones if method == cv2.TM_SQDIFF_NORMED else numpy.zeros)( - (lvl_haystack.shape[0] - lvl_needle.shape[0] + 1, lvl_haystack.shape[1] - lvl_needle.shape[1] + 1), - dtype=numpy.float32)) - - # Scale up region of interest for the next level in the pyramid - # (if it's been set and is a valid size) - if roi_mask is not None: - if any(x < 3 for x in roi_mask.shape): - roi_mask = None - else: - roi_mask = cv2.pyrUp(roi_mask) - - # If roi_mask is set, only search the best candidates in haystack - # for the needle: - - if roi_mask is None: - # Initialize mask to the whole image - rois = [(0, 0, matches_heatmap.shape[1], matches_heatmap.shape[0])] - else: - # Depending on version of OpenCV, findContours returns either a three-tuple - # or a two-tuple. Unsure why the install is different (possibly linked to - # OS version). - try: - _, contours, _ = cv2.findContours( - roi_mask, - cv2.RETR_EXTERNAL, - cv2.CHAIN_APPROX_NONE) - except ValueError: - contours, _ = cv2.findContours( - roi_mask, - cv2.RETR_EXTERNAL, - cv2.CHAIN_APPROX_NONE) - # Expand contour rect by 1px on all sides with some tuple magic - rois = [tuple(sum(y) for y in zip(cv2.boundingRect(x), (-10, -10, 20, 20))) for x in contours] - - - for roi in rois: - # Add needle dimensions to roi - x, y, w, h = roi - # Contour coordinates may be negative, which will mess up the slices. - # Snap to zero if they are. - x = max(0, x) - y = max(0, y) - - roi = (x, y, w+lvl_needle.shape[1]-1, h+lvl_needle.shape[0]-1) - # numpy 2D slice - roi_slice = (slice(roi[1], roi[1]+roi[3]), slice(roi[0], roi[0]+roi[2])) - - r_slice = (slice(y, y+h), slice(x, x+w)) # numpy 2D slice - - # Search the region of interest for needle (and update heatmap) - matches_heatmap[r_slice] = cv2.matchTemplate( - lvl_haystack[roi_slice], - lvl_needle, - method) - min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(matches_heatmap) - # Reduce similarity to allow for scaling distortion - # (unless we are on the original image) - Debug.log(3, "Best match: {} at {}".format(max_val, max_loc)) - pyr_similarity = max(0, similarity - (0.2 if level < len(haystack_pyramid)-1 else 0)) - positions = [] - confidence = None - - # Check for a match - # Review the 100 top matches - indices = (-matches_heatmap).argpartition(100, axis=None)[:100] - unraveled_indices = numpy.array(numpy.unravel_index(indices, matches_heatmap.shape)).T - for location in unraveled_indices: - y, x = location - confidence = matches_heatmap[y][x] - if method == cv2.TM_SQDIFF_NORMED or method == cv2.TM_SQDIFF: - if confidence <= 1-pyr_similarity: - positions.append(((x, y), confidence)) - else: - if confidence >= pyr_similarity: - positions.append(((x, y), confidence)) - - if not len(positions): + # Use findBestMatch to get the best match + while True: + best_match = self.findBestMatch(needle, similarity) + if best_match is None: # No more matches break - - # Find the best regions of interest - _, roi_mask = cv2.threshold( - # Source image - matches_heatmap, - # Confidence threshold - ((1-pyr_similarity) if method == cv2.TM_SQDIFF_NORMED else pyr_similarity), - # Max value - 255, - # Thresholding style - (cv2.THRESH_BINARY_INV if method == cv2.TM_SQDIFF_NORMED else cv2.THRESH_BINARY)) - roi_mask = roi_mask.astype(numpy.uint8) + # Found a match. Add it to our list + positions.append(best_match) # (position, confidence) + + # Erase the found match from the haystack. + # Repeat this process until no other matches are found + x, y = best_match[0] + w = needle.shape[1] + h = needle.shape[0] + roi = (x, y, w, h) + # numpy 2D slice + roi_slice = (slice(roi[1], roi[1]+roi[3]), slice(roi[0], roi[0]+roi[2])) + self.haystack[roi_slice] = 0 # Whew! Let's see if there's a match after all that. positions.sort(key=lambda x: (x[0][1], x[0][0])) @@ -329,3 +251,8 @@ def _build_pyramid(self, image, levels): break pyramid.append(cv2.pyrDown(pyramid[-1])) return list(reversed(pyramid)) + def _is_solid_color(self, image): + return image.ptp() == 0 + + def _is_solid_black(self, image): + return image.mean() == 0 \ No newline at end of file diff --git a/lackey/__init__.py b/lackey/__init__.py index ce0bcdb..ec9920a 100644 --- a/lackey/__init__.py +++ b/lackey/__init__.py @@ -14,6 +14,7 @@ from tkinter import messagebox as tkMessageBox import platform +import keyboard import sys import time import os @@ -23,7 +24,10 @@ from .PlatformManagerWindows import PlatformManagerWindows from .KeyCodes import Button, Key, KeyModifier -from .RegionMatching import Pattern, Region, Match, Screen, Location, Mouse, Keyboard, App +from .RegionMatching import Pattern, Region, Match, Screen +from .Location import Location +from .InputEmulation import Mouse, Keyboard +from .App import App from .Exceptions import FindFailed from .Settings import Debug, Settings, DebugMaster, SettingsMaster from . import SikuliGui @@ -31,6 +35,9 @@ VALID_PLATFORMS = ["Windows"] +## Define script abort hotkey (Alt+Shift+C) +keyboard.add_hotkey("alt+shift+c", sys.exit) + ## Sikuli patching: Functions that map to the global Screen region ## Don't try this at home, kids! diff --git a/lackey/_version.py b/lackey/_version.py index 0a7357e..abf2ad9 100644 --- a/lackey/_version.py +++ b/lackey/_version.py @@ -2,4 +2,5 @@ """ -__version__ = "0.5.3" +__version__ = "0.5.4" +__sikuli_version__ = "1.1.0" diff --git a/requirements.txt b/requirements.txt index 8c8c01a..0176a30 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,5 @@ numpy pillow opencv-python wheel -keyboard \ No newline at end of file +keyboard>=v0.9.11 +twine \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..a193284 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Makes tests runnable with `python -m unittest tests.test_cases` diff --git a/tests/appveyor_test_cases.py b/tests/appveyor_test_cases.py index 282a50a..62677f2 100644 --- a/tests/appveyor_test_cases.py +++ b/tests/appveyor_test_cases.py @@ -4,7 +4,6 @@ import time import sys import os -#sys.path.insert(0, os.path.abspath('..')) import lackey class TestLocationMethods(unittest.TestCase): @@ -63,37 +62,6 @@ 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)) -#class TestMouseMethods(unittest.TestCase): -# def setUp(self): -# self.mouse = lackey.Mouse() -# -# def test_movement(self): -# self.mouse.move(lackey.Location(10,10)) -# self.assertEqual(self.mouse.getPos().getTuple(), (10,10)) -# self.mouse.moveSpeed(lackey.Location(100,200), 1) -# self.assertEqual(self.mouse.getPos().getTuple(), (100,200)) -# -# def test_clicks(self): -# """ -# Not sure how to build these tests yet -# """ -# pass - -# 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 TestInterfaces(unittest.TestCase): """ This class tests Sikuli interface compatibility on a surface level. Makes sure the class has the correct methods, and that the methods have the @@ -230,19 +198,6 @@ def test_screen_interface(self): def test_platform_manager_interface(self): """ Checking Platform Manager interface methods """ - ## Keyboard input methods - self.assertHasMethod(lackey.PlatformManagerWindows, "pressKey", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "releaseKey", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "typeKeys", 3) - - ## Mouse input methods - self.assertHasMethod(lackey.PlatformManagerWindows, "setMousePos", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "getMousePos", 1) - self.assertHasMethod(lackey.PlatformManagerWindows, "mouseButtonDown", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "mouseButtonUp", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "clickMouse", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "mouseWheel", 3) - ## Screen methods self.assertHasMethod(lackey.PlatformManagerWindows, "getBitmapFromRect", 5) self.assertHasMethod(lackey.PlatformManagerWindows, "getScreenBounds", 2) @@ -276,127 +231,6 @@ def assertHasMethod(self, cls, mthd, args=0): if args > 0: self.assertEqual(len(inspect.getargspec(getattr(cls, mthd))[0]), args) -# class TestAppMethods(unittest.TestCase): -# def setUp(self): -# if sys.platform.startswith("win"): -# self.app = lackey.App("notepad.exe") -# self.app.setUsing("test_cases.py") -# self.app.open() -# time.sleep(1) -# else: -# raise NotImplementedError("Platforms supported include: Windows") -# self.app.focus() -# def tearDown(self): -# if sys.platform.startswith("win"): -# self.app.close() -# time.sleep(1) - -# def test_getters(self): -# self.assertEqual(self.app.getName(), "notepad.exe") -# self.assertTrue(self.app.isRunning()) -# self.assertEqual(self.app.getWindow(), "test_cases.py - Notepad") -# self.assertNotEqual(self.app.getPID(), -1) -# region = self.app.window() -# self.assertIsInstance(region, lackey.Region) -# self.assertGreater(region.getW(), 0) -# self.assertGreater(region.getH(), 0) - -# 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, "") - -# class TestComplexFeatures(unittest.TestCase): -# def testTypeCopyPaste(self): -# if sys.platform.startswith("win"): -# app = lackey.App("notepad.exe").open() -# time.sleep(1) -# else: -# raise NotImplementedError("Platforms supported include: Windows") -# r = app.window() - -# r.type("This is a +test") # Type should translate "+" into shift modifier for capital first letters -# r.type("^a") # Select all -# r.type("^c") # 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 +broken +record.") # Paste should ignore special characters and insert the string as is -# r.type("^a") # Select all -# r.type("^c") # Copy -# self.assertEqual(r.getClipboard(), "This, on the other hand, is a +broken +record.") - -# if sys.platform.startswith("win"): -# app.close() - -# def testOpenApp(self): -# """ This looks for the specified Notepad icon on the desktop. - -# This test will probably fail if you don't have the same setup I do. -# """ -# r = lackey.Screen(0) -# r.doubleClick("notepad.png") -# time.sleep(2) -# r.type("This is a test") -# r.rightClick(lackey.Pattern("test_text.png").similar(0.6)) -# r.click("select_all.png") -# r.type("^c") # Copy -# self.assertEqual(r.getClipboard(), "This is a test") -# r.type("{DELETE}") -# r.type("%{F4}") - -# class TestRegionFeatures(unittest.TestCase): -# def setUp(self): -# self.r = lackey.Screen(0) - -# def testValidityMethods(self): -# self.assertTrue(self.r.isRegionValid()) -# clipped = self.r.clipRegionToScreen() -# self.assertIsNotNone(clipped) -# self.assertEqual(clipped.getX(), self.r.getX()) -# self.assertEqual(clipped.getY(), self.r.getY()) -# self.assertEqual(clipped.getW(), self.r.getW()) -# self.assertEqual(clipped.getH(), self.r.getH()) - -# def testAroundMethods(self): -# center_region = self.r.get(lackey.Region.MID_BIG) -# below_region = center_region.below() -# self.assertTrue(below_region.isRegionValid()) -# above_region = center_region.above() -# self.assertTrue(center_region.isRegionValid()) -# right_region = center_region.right() -# self.assertTrue(right_region.isRegionValid()) -# left_region = center_region.left() -# self.assertTrue(left_region.isRegionValid()) -# nearby_region = center_region.nearby(10) -# self.assertTrue(nearby_region.isRegionValid()) -# grow_region = center_region.grow(10, 5) -# self.assertTrue(grow_region.isRegionValid()) - -# class TestRasterMethods(unittest.TestCase): -# def setUp(self): -# self.r = lackey.Screen(0) - -# def testRaster(self): -# # This should preview the specified sections of the primary screen. -# self.r.debugPreview("Full screen") -# self.r.get(lackey.Region.NORTH).debugPreview("Top half") -# self.r.get(lackey.Region.SOUTH).debugPreview("Bottom half") -# self.r.get(lackey.Region.NORTH_WEST).debugPreview("Upper right corner") -# self.r.get(522).debugPreview("Center (small)") -# self.r.get(lackey.Region.MID_BIG).debugPreview("Center (half)") - class TestConvenienceFunctions(unittest.TestCase): def test_function_defs(self): self.assertHasMethod(lackey, "sleep", 1) diff --git a/tests/test_cases.py b/tests/test_cases.py index fbe870d..8424957 100644 --- a/tests/test_cases.py +++ b/tests/test_cases.py @@ -7,68 +7,14 @@ #sys.path.insert(0, os.path.abspath('..')) import lackey +from appveyor_test_cases import TestLocationMethods, TestPatternMethods, TestInterfaces, TestConvenienceFunctions + # Python 3 compatibility try: basestring except NameError: basestring = str -class TestLocationMethods(unittest.TestCase): - def setUp(self): - self.test_loc = lackey.Location(10, 11) - - def test_getters(self): - self.assertEqual(self.test_loc.getX(), 10) - self.assertEqual(self.test_loc.getY(), 11) - self.assertEqual(self.test_loc.getTuple(), (10,11)) - self.assertEqual(str(self.test_loc), "(Location object at (10,11))") - - def test_set_location(self): - self.test_loc.setLocation(3, 5) - self.assertEqual(self.test_loc.getX(), 3) - self.assertEqual(self.test_loc.getY(), 5) - self.test_loc.setLocation(-3, 1009) - self.assertEqual(self.test_loc.getX(), -3) - self.assertEqual(self.test_loc.getY(), 1009) - - def test_offsets(self): - offset = self.test_loc.offset(3, -5) - self.assertEqual(offset.getTuple(), (13,6)) - offset = self.test_loc.above(10) - self.assertEqual(offset.getTuple(), (10,1)) - offset = self.test_loc.below(16) - self.assertEqual(offset.getTuple(), (10,27)) - offset = self.test_loc.right(5) - self.assertEqual(offset.getTuple(), (15,11)) - offset = self.test_loc.left(7) - self.assertEqual(offset.getTuple(), (3,11)) - -class TestPatternMethods(unittest.TestCase): - def setUp(self): - self.pattern = lackey.Pattern("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("test_pattern.png"):], "test_pattern.png") - - def test_setters(self): - test_pattern = self.pattern.similar(0.5) - self.assertEqual(test_pattern.similarity, 0.5) - self.assertEqual(self.pattern.path[-len("test_pattern.png"):], "test_pattern.png") - test_pattern = self.pattern.exact() - self.assertEqual(test_pattern.similarity, 1.0) - self.assertEqual(self.pattern.path[-len("test_pattern.png"):], "test_pattern.png") - test_pattern = self.pattern.targetOffset(3, 5) - self.assertEqual(test_pattern.similarity, 0.7) - self.assertEqual(self.pattern.path[-len("test_pattern.png"):], "test_pattern.png") - self.assertEqual(test_pattern.offset.getTuple(), (3,5)) - - def test_getters(self): - self.assertEqual(self.pattern.getFilename()[-len("test_pattern.png"):], "test_pattern.png") - self.assertEqual(self.pattern.getTargetOffset().getTuple(), (0,0)) - class TestMouseMethods(unittest.TestCase): def setUp(self): self.mouse = lackey.Mouse() @@ -100,196 +46,10 @@ def test_keys(self): # you run this test, the SHIFT, CTRL, or ALT keys might not have been released # properly. -class TestInterfaces(unittest.TestCase): - """ This class tests Sikuli interface compatibility on a surface level. - - Makes sure the class has the correct methods, and that the methods have the - expected number of arguments. - """ - def test_app_interface(self): - """ Checking App class interface methods """ - ## Class methods - self.assertHasMethod(lackey.App, "pause", 2) - self.assertHasMethod(lackey.App, "open", 2) - self.assertHasMethod(lackey.App, "focus", 2) - self.assertHasMethod(lackey.App, "close", 2) - self.assertHasMethod(lackey.App, "focusedWindow", 1) - - ## Instance methods - app = lackey.App() - self.assertHasMethod(app, "__init__", 2) - self.assertHasMethod(app, "isRunning", 2) - self.assertHasMethod(app, "hasWindow", 1) - self.assertHasMethod(app, "getWindow", 1) - self.assertHasMethod(app, "getPID", 1) - self.assertHasMethod(app, "getName", 1) - self.assertHasMethod(app, "setUsing", 2) - self.assertHasMethod(app, "open", 2) - self.assertHasMethod(app, "focus", 1) - self.assertHasMethod(app, "close", 1) - self.assertHasMethod(app, "window", 2) - - def test_region_interface(self): - """ Checking Region class interface methods """ - self.assertHasMethod(lackey.Region, "__init__", 1) # uses *args - self.assertHasMethod(lackey.Region, "setX", 2) - self.assertHasMethod(lackey.Region, "setY", 2) - self.assertHasMethod(lackey.Region, "setW", 2) - self.assertHasMethod(lackey.Region, "setH", 2) - self.assertHasMethod(lackey.Region, "moveTo", 2) - self.assertHasMethod(lackey.Region, "setROI", 1) # uses *args - self.assertHasMethod(lackey.Region, "setRect", 1) # uses *args - self.assertHasMethod(lackey.Region, "morphTo", 2) - self.assertHasMethod(lackey.Region, "getX", 1) - self.assertHasMethod(lackey.Region, "getY", 1) - self.assertHasMethod(lackey.Region, "getW", 1) - self.assertHasMethod(lackey.Region, "getH", 1) - self.assertHasMethod(lackey.Region, "getTopLeft", 1) - self.assertHasMethod(lackey.Region, "getTopRight", 1) - self.assertHasMethod(lackey.Region, "getBottomLeft", 1) - self.assertHasMethod(lackey.Region, "getBottomRight", 1) - self.assertHasMethod(lackey.Region, "getScreen", 1) - self.assertHasMethod(lackey.Region, "getLastMatch", 1) - self.assertHasMethod(lackey.Region, "getLastMatches", 1) - self.assertHasMethod(lackey.Region, "getTime", 1) - self.assertHasMethod(lackey.Region, "isRegionValid", 1) - self.assertHasMethod(lackey.Region, "setAutoWaitTimeout", 2) - self.assertHasMethod(lackey.Region, "getAutoWaitTimeout", 1) - self.assertHasMethod(lackey.Region, "setWaitScanRate", 2) - self.assertHasMethod(lackey.Region, "getWaitScanRate", 1) - self.assertHasMethod(lackey.Region, "get", 2) - self.assertHasMethod(lackey.Region, "getRow", 3) - self.assertHasMethod(lackey.Region, "getCol", 3) - self.assertHasMethod(lackey.Region, "setRows", 2) - self.assertHasMethod(lackey.Region, "setCols", 2) - self.assertHasMethod(lackey.Region, "setRaster", 3) - self.assertHasMethod(lackey.Region, "getCell", 3) - self.assertHasMethod(lackey.Region, "isRasterValid", 1) - self.assertHasMethod(lackey.Region, "getRows", 1) - self.assertHasMethod(lackey.Region, "getCols", 1) - self.assertHasMethod(lackey.Region, "getRowH", 1) - self.assertHasMethod(lackey.Region, "getColW", 1) - self.assertHasMethod(lackey.Region, "offset", 3) - self.assertHasMethod(lackey.Region, "inside", 1) - self.assertHasMethod(lackey.Region, "grow", 3) - self.assertHasMethod(lackey.Region, "nearby", 2) - self.assertHasMethod(lackey.Region, "above", 2) - self.assertHasMethod(lackey.Region, "below", 2) - self.assertHasMethod(lackey.Region, "left", 2) - self.assertHasMethod(lackey.Region, "right", 2) - self.assertHasMethod(lackey.Region, "find", 2) - self.assertHasMethod(lackey.Region, "findAll", 2) - self.assertHasMethod(lackey.Region, "wait", 3) - self.assertHasMethod(lackey.Region, "waitVanish", 3) - self.assertHasMethod(lackey.Region, "exists", 3) - self.assertHasMethod(lackey.Region, "click", 3) - self.assertHasMethod(lackey.Region, "doubleClick", 3) - self.assertHasMethod(lackey.Region, "rightClick", 3) - self.assertHasMethod(lackey.Region, "highlight", 2) - self.assertHasMethod(lackey.Region, "hover", 2) - self.assertHasMethod(lackey.Region, "dragDrop", 4) - self.assertHasMethod(lackey.Region, "drag", 2) - self.assertHasMethod(lackey.Region, "dropAt", 3) - self.assertHasMethod(lackey.Region, "type", 1) # Uses *args - self.assertHasMethod(lackey.Region, "paste", 1) # Uses *args - self.assertHasMethod(lackey.Region, "text", 1) - self.assertHasMethod(lackey.Region, "mouseDown", 2) - self.assertHasMethod(lackey.Region, "mouseUp", 2) - self.assertHasMethod(lackey.Region, "mouseMove", 3) - self.assertHasMethod(lackey.Region, "wheel", 4) - self.assertHasMethod(lackey.Region, "keyDown", 2) - self.assertHasMethod(lackey.Region, "keyUp", 2) - - def test_pattern_interface(self): - """ Checking App class interface methods """ - self.assertHasMethod(lackey.Pattern, "__init__", 2) - self.assertHasMethod(lackey.Pattern, "similar", 2) - self.assertHasMethod(lackey.Pattern, "exact", 1) - self.assertHasMethod(lackey.Pattern, "targetOffset", 3) - self.assertHasMethod(lackey.Pattern, "getFilename", 1) - self.assertHasMethod(lackey.Pattern, "getTargetOffset", 1) - - def test_match_interface(self): - """ Checking Match class interface methods """ - self.assertHasMethod(lackey.Match, "getScore", 1) - self.assertHasMethod(lackey.Match, "getTarget", 1) - - def test_location_interface(self): - """ Checking Match class interface methods """ - self.assertHasMethod(lackey.Location, "__init__", 3) - self.assertHasMethod(lackey.Location, "getX", 1) - self.assertHasMethod(lackey.Location, "getY", 1) - self.assertHasMethod(lackey.Location, "setLocation", 3) - self.assertHasMethod(lackey.Location, "offset", 3) - self.assertHasMethod(lackey.Location, "above", 2) - self.assertHasMethod(lackey.Location, "below", 2) - self.assertHasMethod(lackey.Location, "left", 2) - self.assertHasMethod(lackey.Location, "right", 2) - - def test_screen_interface(self): - """ Checking Match class interface methods """ - self.assertHasMethod(lackey.Screen, "__init__", 2) - self.assertHasMethod(lackey.Screen, "getNumberScreens", 1) - self.assertHasMethod(lackey.Screen, "getBounds", 1) - self.assertHasMethod(lackey.Screen, "capture", 1) # Uses *args - self.assertHasMethod(lackey.Screen, "selectRegion", 2) - - def test_platform_manager_interface(self): - """ Checking Platform Manager interface methods """ - - ## Keyboard input methods - self.assertHasMethod(lackey.PlatformManagerWindows, "pressKey", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "releaseKey", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "typeKeys", 3) - - ## Mouse input methods - self.assertHasMethod(lackey.PlatformManagerWindows, "setMousePos", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "getMousePos", 1) - self.assertHasMethod(lackey.PlatformManagerWindows, "mouseButtonDown", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "mouseButtonUp", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "clickMouse", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "mouseWheel", 3) - - ## 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) - - ## Clipboard methods - self.assertHasMethod(lackey.PlatformManagerWindows, "getClipboard", 1) - self.assertHasMethod(lackey.PlatformManagerWindows, "setClipboard", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "osCopy", 1) - self.assertHasMethod(lackey.PlatformManagerWindows, "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) - - ## Process methods - self.assertHasMethod(lackey.PlatformManagerWindows, "isPIDValid", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "killProcess", 2) - self.assertHasMethod(lackey.PlatformManagerWindows, "getProcessName", 2) - - def test_version(self): - print(lackey.__version__) - - - 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))) - if args > 0: - self.assertEqual(len(inspect.getargspec(getattr(cls, mthd))[0]), args) - class TestAppMethods(unittest.TestCase): def test_getters(self): if sys.platform.startswith("win"): - app = lackey.App("notepad.exe test_cases.py") + app = lackey.App("notepad.exe tests\\test_cases.py") #app.setUsing("test_cases.py") app.open() time.sleep(1) @@ -312,7 +72,7 @@ def test_getters(self): def test_launchers(self): app = lackey.App("notepad.exe") - app.setUsing("test_cases.py") + app.setUsing("tests\\test_cases.py") app.open() time.sleep(1) self.assertEqual(app.getName(), "notepad.exe") @@ -340,6 +100,9 @@ def testCapture(self): self.assertNotEqual(tpath, "") class TestComplexFeatures(unittest.TestCase): + def setUp(self): + lackey.addImagePath(os.path.dirname(__file__)) + def testTypeCopyPaste(self): if sys.platform.startswith("win"): app = lackey.App("notepad.exe").open() @@ -428,36 +191,5 @@ def testRaster(self): self.r.get(522).debugPreview("Center (small)") self.r.get(lackey.Region.MID_BIG).debugPreview("Center (half)") -class TestConvenienceFunctions(unittest.TestCase): - def test_function_defs(self): - self.assertHasMethod(lackey, "sleep", 1) - self.assertHasMethod(lackey, "exit", 1) - self.assertHasMethod(lackey, "setShowActions", 1) - self.assertHasMethod(lackey, "getBundlePath", 0) - self.assertHasMethod(lackey, "getBundleFolder", 0) - self.assertHasMethod(lackey, "setBundlePath", 1) - self.assertHasMethod(lackey, "getImagePath", 0) - self.assertHasMethod(lackey, "addImagePath", 1) - self.assertHasMethod(lackey, "addHTTPImagePath", 1) - self.assertHasMethod(lackey, "getParentPath", 0) - self.assertHasMethod(lackey, "getParentFolder", 0) - self.assertHasMethod(lackey, "makePath", 0) # Uses *args - self.assertHasMethod(lackey, "makeFolder", 0) # Uses *args - self.assertHasMethod(lackey, "unzip", 2) - self.assertHasMethod(lackey, "popat", 0) # Uses *args - self.assertHasMethod(lackey, "popup", 2) - self.assertHasMethod(lackey, "popError", 2) - self.assertHasMethod(lackey, "popAsk", 2) - self.assertHasMethod(lackey, "input", 4) - self.assertHasMethod(lackey, "inputText", 5) - self.assertHasMethod(lackey, "select", 4) - self.assertHasMethod(lackey, "popFile", 1) - - 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))) - if args > 0: - self.assertEqual(len(inspect.getargspec(getattr(cls, mthd))[0]), args) - if __name__ == '__main__': unittest.main() \ No newline at end of file