From 39eec72111105ea83adfd6ff5b64a104be670b65 Mon Sep 17 00:00:00 2001 From: Spencer Young Date: Mon, 26 Nov 2018 18:46:56 -0800 Subject: [PATCH 01/10] adds mouse and window modules; directives; foundational work --- ahk/__init__.py | 2 +- ahk/autohotkey.py | 94 ++------------------------- ahk/directives.py | 160 ++++++++++++++++++++++++++++++++++++++++++++++ ahk/mouse.py | 60 +++++++++++++++++ ahk/script.py | 45 +++++++++++++ ahk/utils.py | 32 ++++++++++ ahk/window.py | 145 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 447 insertions(+), 91 deletions(-) create mode 100644 ahk/directives.py create mode 100644 ahk/mouse.py create mode 100644 ahk/script.py create mode 100644 ahk/utils.py create mode 100644 ahk/window.py diff --git a/ahk/__init__.py b/ahk/__init__.py index 04a4d8fd..cca752f5 100644 --- a/ahk/__init__.py +++ b/ahk/__init__.py @@ -1,2 +1,2 @@ from ahk.autohotkey import AHK -__all__ = ['AHK'] \ No newline at end of file +__all__ = ['AHK'] diff --git a/ahk/autohotkey.py b/ahk/autohotkey.py index 667da5fe..18f8a016 100644 --- a/ahk/autohotkey.py +++ b/ahk/autohotkey.py @@ -1,92 +1,6 @@ -from tempfile import NamedTemporaryFile -import os -import ast -import subprocess -import shutil -from textwrap import dedent -import time -from contextlib import suppress -import logging +from ahk.mouse import MouseMixin +from ahk.window import WindowMixin -class AHK(object): - def __init__(self, executable_path: str='', keep_scripts: bool=False): - """ - :param executable_path: the path to the AHK executable. Defaults to environ['AHK_PATH'] otherwise tries 'AutoHotkeyA32.exe' - :param keep_scripts: - :raises RuntimeError: if AHK executable is not provided and cannot be found in environment variables or PATH - """ - self.speed = 2 - self.keep_scripts = bool(keep_scripts) - executable_path = executable_path or os.environ.get('AHK_PATH') or shutil.which('AutoHotkey.exe') or shutil.which('AutoHotkeyA32.exe') - self.executable_path = executable_path - - def _run_script(self, script_path, **kwargs): - result = subprocess.run([self.executable_path, script_path], stdin=None, stderr=None, stdout=subprocess.PIPE, **kwargs) - return result.stdout.decode() - - def run_script(self, script_text:str, delete=None, blocking=True, **runkwargs): - if blocking is False: - script_text = script_text.lstrip('#Persistent') - if delete is None: - delete = not self.keep_scripts - try: - with NamedTemporaryFile(mode='w', delete=False) as temp_script: - temp_script.write(script_text) - result = self._run_script(temp_script.name) - except Exception as e: - logging.critical('Something went terribly wrong: %s', e) - result = None - finally: - if delete: - with suppress(OSError): - os.remove(temp_script.name) - return result - - def _mouse_position(self): - return dedent(''' - #Persistent - MouseGetPos, xpos, ypos - s .= Format("({}, {})", xpos, ypos) - FileAppend, %s%, * - ExitApp - ''') - - @property - def mouse_position(self): - response = self.run_script(self._mouse_position()) - return ast.literal_eval(response) - - @mouse_position.setter - def mouse_position(self, position): - x, y = position - self.move_mouse(x=x, y=y, speed=0, relative=False) - - def _move_mouse(self, x=None, y=None, speed=None, relative=False): - if x is None and y is None: - raise ValueError('Position argument(s) missing. Must provide x and/or y coordinates') - if speed is None: - speed = self.speed - if relative and (x is None or y is None): - x = x or 0 - y = y or 0 - elif not relative and (x is None or y is None): - posx, posy = self.mouse_position - x = x or posx - y = y or posy - - if relative: - relative = ', R' - else: - relative = '' - script = dedent(f'''\ - #Persistent - MouseMove, {x}, {y} , {speed}{relative} - ExitApp - ''') - return script - - def move_mouse(self, *args, **kwargs): - script = self._move_mouse(*args, **kwargs) - response = self.run_script(script_text=script) - return response or None +class AHK(WindowMixin, MouseMixin): + pass diff --git a/ahk/directives.py b/ahk/directives.py new file mode 100644 index 00000000..82f97f8d --- /dev/null +++ b/ahk/directives.py @@ -0,0 +1,160 @@ +from types import SimpleNamespace + + +class DirectiveMeta(type): + """ + Overrides __str__ so directives with no arguments can be used without instantiation + Overrides __hash__ to make objects 'unique' based upon a hash of the str representation + """ + def __str__(cls): + return f"#{cls.__name__}" + + def __hash__(self): + return hash(str(self)) + + def __eq__(cls, other): + return str(cls) == other + + +class Directive(SimpleNamespace, metaclass=DirectiveMeta): + """ + Simple directive class + They are designed to be hashable and comparable with string equivalent of AHK directive. + Directives that don't require arguments do not need to be instantiated. + """ + def __init__(self, **kwargs): + super().__init__(name=self.name, **kwargs) + self._kwargs = kwargs + + @property + def name(self): + return self.__class__.__name__ + + def __str__(self): + if self._kwargs: + arguments = ' '.join(str(value) for key, value in self._kwargs.items()) + else: + arguments = '' + return f"#{self.name} {arguments}".rstrip() + + def __eq__(self, other): + return str(self) == other + + def __hash__(self): + return hash(str(self)) + + +class AllowSameLineComments(Directive): + pass + + +class ClipboardTimeout(Directive): + def __init__(self, milliseconds=0, **kwargs): + kwargs['milliseconds'] = milliseconds + super().__init__(**kwargs) + + +class ErrorStdOut(Directive): + pass + + +class HotKeyInterval(ClipboardTimeout): + pass + + +class HotKeyModifierTimeout(HotKeyInterval): + pass + + +class Include(Directive): + def __init__(self, include_name, **kwargs): + kwargs['include_name'] = include_name + super().__init__(**kwargs) + + +class IncludeAgain(Include): + pass + + +class InputLevel(Directive): + def __init__(self, level, **kwargs): + kwargs['level'] = level + super().__init__(**kwargs) + + +class InstallKeybdHook(Directive): + pass + + +class InstallMouseHook(Directive): + pass + + +class KeyHistory(Directive): + def __init__(self, limit=40, **kwargs): + kwargs['limit'] = limit + super().__init__(**kwargs) + + +class MaxHotkeysPerInterval(Directive): + def __init__(self, value, **kwargs): + kwargs['value'] = value + super().__init__(**kwargs) + + +class MaxMem(Directive): + def __init__(self, megabytes: int, **kwargs): + if megabytes < 1: + raise ValueError('megabytes cannot be less than 1') + if megabytes > 4095: + raise ValueError('megabytes cannot exceed 4095') + kwargs['megabytes'] = megabytes + super().__init__(**kwargs) + + +class MaxThreads(Directive): + def __init__(self): + raise NotImplemented + + +class MaxThreadsBuffer(Directive): + def __init__(self): + raise NotImplemented + + +class MaxThreadsPerHotkey(Directive): + def __init__(self): + raise NotImplemented + + +class MenuMaskKey(Directive): + def __init__(self): + raise NotImplemented + + +class NoEnv(Directive): + pass + + +class NoTrayIcon(Directive): + pass + + +class Persistent(Directive): + pass + + +class SingleInstance(Directive): + pass + + +class UseHook(Directive): + pass + + +class Warn(Directive): + pass + + +class WinActivateForce(Directive): + pass diff --git a/ahk/mouse.py b/ahk/mouse.py new file mode 100644 index 00000000..4891db48 --- /dev/null +++ b/ahk/mouse.py @@ -0,0 +1,60 @@ +from ahk.utils import make_script +from ahk.script import ScriptEngine +import ast + + +class MouseMixin(ScriptEngine): + def __init__(self, mouse_speed=2, **kwargs): + self._mouse_speed = mouse_speed + super().__init__(**kwargs) + + @property + def mouse_speed(self): + if callable(self._mouse_speed): + return self._mouse_speed() + else: + return self._mouse_speed + + @staticmethod + def _mouse_position(): + return make_script(''' + MouseGetPos, xpos, ypos + s .= Format("({}, {})", xpos, ypos) + FileAppend, %s%, * + ''') + + @property + def mouse_position(self): + response = self.run_script(self._mouse_position()) + return ast.literal_eval(response) + + @mouse_position.setter + def mouse_position(self, position): + x, y = position + self.mouse_move(x=x, y=y, speed=0, relative=False) + + def _mouse_move(self, x=None, y=None, speed=None, relative=False): + if x is None and y is None: + raise ValueError('Position argument(s) missing. Must provide x and/or y coordinates') + if speed is None: + speed = self.mouse_speed + if relative and (x is None or y is None): + x = x or 0 + y = y or 0 + elif not relative and (x is None or y is None): + posx, posy = self.mouse_position + x = x or posx + y = y or posy + + if relative: + relative = ', R' + else: + relative = '' + script = make_script(f'''\ + MouseMove, {x}, {y} , {speed}{relative} + ''') + return script + + def mouse_move(self, *args, **kwargs): + script = self._mouse_move(*args, **kwargs) + self.run_script(script_text=script) diff --git a/ahk/script.py b/ahk/script.py new file mode 100644 index 00000000..7235cdd1 --- /dev/null +++ b/ahk/script.py @@ -0,0 +1,45 @@ +from tempfile import NamedTemporaryFile +import os +import subprocess +from contextlib import suppress +from shutil import which +from ahk.utils import logger + + +class ScriptEngine(object): + def __init__(self, executable_path: str='', keep_scripts: bool=False, **kwargs): + """ + :param executable_path: the path to the AHK executable. + Defaults to environ['AHK_PATH'] if not explicitly provided + If environment variable not present, tries to look for 'AutoHotkey.exe' or 'AutoHotkeyA32.exe' with shutil.which + :param keep_scripts: + :raises RuntimeError: if AHK executable is not provided and cannot be found in environment variables or PATH + """ + self.keep_scripts = bool(keep_scripts) + if not executable_path: + executable_path = os.environ.get('AHK_PATH') or which('AutoHotkey.exe') or which('AutoHotkeyA32.exe') + self.executable_path = executable_path + + def _run_script(self, script_path, **kwargs): + runargs = [self.executable_path, script_path] + result = subprocess.run(runargs, stdin=None, stderr=None, stdout=subprocess.PIPE, **kwargs) + return result.stdout.decode() + + def run_script(self, script_text: str, delete=None, **runkwargs): + if delete is None: + delete = not self.keep_scripts + with NamedTemporaryFile(mode='w', delete=False, newline='\r\n') as temp_script: + temp_script.write(script_text) + logger.debug('Script location: %s', temp_script.name) + logger.debug('Script text: \n%s', script_text) + try: + result = self._run_script(temp_script.name, **runkwargs) + except Exception as e: + logger.fatal('Error running temp script: %s', e) + raise + finally: + if delete: + logger.debug('cleaning up temp script') + with suppress(OSError): + os.remove(temp_script.name) + return result diff --git a/ahk/utils.py b/ahk/utils.py new file mode 100644 index 00000000..735a5b92 --- /dev/null +++ b/ahk/utils.py @@ -0,0 +1,32 @@ +from textwrap import dedent +import logging + +logger = logging.getLogger('ahk') + + +def make_script(body, directives=None, persistent=True): + """ + Convenience function to dedent script body as well as add #Persistent directive and Exit/ExitApp + :param body: body of the script + :param directives: an iterable of directives to add to the script. + :type directives: str or ahk.directives.Directive + :param persistent: if the #Persistent directive should be added (causes script to be blocking) + :return: + """ + exit_ = 'ExitApp' + if directives is None: + directives = set() + else: + directives = set(directives) + + if persistent: + directives.add('#Persistent') + + dirs = '\n'.join(str(directive) for directive in directives) + + script = dedent(f'''\ + {dirs} + {body} + {exit_} + ''') + return script diff --git a/ahk/window.py b/ahk/window.py new file mode 100644 index 00000000..c77373b8 --- /dev/null +++ b/ahk/window.py @@ -0,0 +1,145 @@ +from ahk.script import ScriptEngine +from ahk.utils import make_script +import ast + + +class WindowNotFoundError(ValueError): + pass + + +class Window(object): + def __init__(self, engine, title='', text='', exclude_title='', exclude_text='', match_mode=None): + self.engine = engine + if title is None and text is None: + raise ValueError + self._title = title + self._text = text + self._exclude_title = exclude_title + self._exclude_text = exclude_text + self.match_mode = match_mode + + def _win_set(self, subcommand, value): + script = make_script(f'''\ + WinSet, {subcommand}, {value}, {self.title}, {self.text, self._exclude_title, self._exclude_text} + ''') + return script + + def win_set(self, *args, **kwargs): + script = self._win_set(*args, **kwargs) + self.engine.run_script(script) + + def _position(self): + return make_script(f''' + WinGetPos, x, y, width, height, {self.title}, {self.text}, {self._exclude_title}, {self._exclude_text} + s .= Format("({{}}, {{}}, {{}}, {{}})", x, y, width, height) + FileAppend, %s%, * + ''') + + def _get_pos(self): + resp = self.engine.run_script(self._position()) + try: + value = ast.literal_eval(resp) + return value + except SyntaxError: + raise WindowNotFoundError('No window found') + + @property + def position(self): + x, y, _, _ = self._get_pos() + return x, y + + @property + def width(self): + _, _, width, _ = self._get_pos() + return width + + @property + def height(self): + _, _, _, height = self._get_pos() + return height + + def disable(self): + self.win_set('Disable', '') + + def enable(self): + self.win_set('Enable', '') + + def redraw(self): + raise NotImplemented + + @property + def title(self): + return self._title + + @property + def text(self): + return '' + + def style(self): + raise NotImplemented + + def ex_style(self): + raise NotImplemented + + def _always_on_top(self): + return make_script(f''' + WinGet, ExStyle, ExStyle, {self._title}, {self._text}, {self._exclude_title}, {self._exclude_text} + if (ExStyle & 0x8) ; 0x8 is WS_EX_TOPMOST. + FileAppend, 1, * + else + FileAppend, 0, * + ''') + + @property + def always_on_top(self): + resp = self.engine.run_script(self._always_on_top()) + return bool(ast.literal_eval(resp)) + + @always_on_top.setter + def always_on_top(self, value): + if value in ('On', True, 1): + self.win_set('AlwaysOnTop', 'On') + elif value in ('Off', False, 0): + self.win_set('AlwaysOnTop', 'Off') + elif value == 'Toggle': + self.win_set('AlwaysOnTop', 'Toggle') + + def _close(self, seconds_to_wait=''): + return make_script(f'''\ + WinClose, {self.title}, {self.text}, {seconds_to_wait}, {self._exclude_title}, {self._exclude_text} + + ''') + + def close(self, seconds_to_wait=''): + self.engine.run_script(self._close(seconds_to_wait=seconds_to_wait)) + + def to_bottom(self): + """ + Sent + :return: + """ + self.win_set('Bottom', '') + + def to_top(self): + self.win_set('Top', '') + + +class WindowMixin(ScriptEngine): + def win_get(self, *args, **kwargs): + return Window(engine=self, *args, **kwargs) + + @property + def active_window(self): + return Window(engine=self, title='A') + + def win_set(self, subcommand, value, **windowkwargs): + win = Window(engine=self, **windowkwargs) + win.win_set(subcommand, value) + return win + + def windows(self): + """ + Return a list of all windows + :return: + """ + raise NotImplemented From 9451c005f6707909028230f9df554b933d100166 Mon Sep 17 00:00:00 2001 From: Spencer Young Date: Mon, 26 Nov 2018 18:48:45 -0800 Subject: [PATCH 02/10] update readme with API changes --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index daabc6b1..288a7290 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # ahk -A (proof-of-concept) Python wrapper around AHK. +A Python wrapper around AHK. # Usage ```python from ahk import AHK ahk = AHK() -ahk.move_mouse(x=100, y=100, speed=10) # blocks until mouse finishes moving +ahk.mouse_move(x=100, y=100, speed=10) # blocks until mouse finishes moving print(ahk.mouse_position) # (100, 100) ``` @@ -32,6 +32,8 @@ ahk = AHK(executable_path=r'C:\ProgramFiles\AutoHotkey\AutoHotkey.exe') # Development -Right now this is just an idea. It may not even be a particularly good one. +Right now this is just an exploration of an idea. It may not even be a particularly good idea. -Not much is implemented right now, but the vision is to provide additional interfaces that mirror the core functionality from the AHK API in a Pythonic way. \ No newline at end of file +There's still a bit to be done in the way of implementation. + +The vision is to provide additional interfaces that implement the most important parts of the AHK API in a Pythonic way. \ No newline at end of file From e8dfd82e286cf243c5af9f329ac9cfc65720e062 Mon Sep 17 00:00:00 2001 From: Spencer Young Date: Mon, 26 Nov 2018 19:31:52 -0800 Subject: [PATCH 03/10] Change default coordinate mode to Screen --- ahk/mouse.py | 12 +++++++++--- ahk/utils.py | 10 ++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/ahk/mouse.py b/ahk/mouse.py index 4891db48..c24adafa 100644 --- a/ahk/mouse.py +++ b/ahk/mouse.py @@ -4,7 +4,10 @@ class MouseMixin(ScriptEngine): - def __init__(self, mouse_speed=2, **kwargs): + def __init__(self, mouse_speed=2, mode=None, **kwargs): + if mode is None: + mode = 'Screen' + self.mode = mode self._mouse_speed = mouse_speed super().__init__(**kwargs) @@ -33,11 +36,13 @@ def mouse_position(self, position): x, y = position self.mouse_move(x=x, y=y, speed=0, relative=False) - def _mouse_move(self, x=None, y=None, speed=None, relative=False): + def _mouse_move(self, x=None, y=None, speed=None, relative=False, mode=None): if x is None and y is None: raise ValueError('Position argument(s) missing. Must provide x and/or y coordinates') if speed is None: speed = self.mouse_speed + if mode is None: + mode = self.mode if relative and (x is None or y is None): x = x or 0 y = y or 0 @@ -50,7 +55,8 @@ def _mouse_move(self, x=None, y=None, speed=None, relative=False): relative = ', R' else: relative = '' - script = make_script(f'''\ + script = make_script(f''' + CoordMode Mouse, {mode} MouseMove, {x}, {y} , {speed}{relative} ''') return script diff --git a/ahk/utils.py b/ahk/utils.py index 735a5b92..018b08a6 100644 --- a/ahk/utils.py +++ b/ahk/utils.py @@ -1,7 +1,17 @@ +import os from textwrap import dedent import logging logger = logging.getLogger('ahk') +handler = logging.StreamHandler() +formatter = logging.Formatter( + '%(asctime)s %(name)-12s %(levelname)-8s %(message)s') +handler.setFormatter(formatter) +logger.addHandler(handler) +if os.environ.get('AHK_DEBUG'): + logger.setLevel(logging.DEBUG) +else: + logger.setLevel(logging.ERROR) def make_script(body, directives=None, persistent=True): From c707fdbce97dd3ec9c456ed4ae4b77ae7f5c2f4a Mon Sep 17 00:00:00 2001 From: Spencer Young Date: Mon, 26 Nov 2018 19:36:51 -0800 Subject: [PATCH 04/10] apply CoordMode to mouse_position --- ahk/mouse.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ahk/mouse.py b/ahk/mouse.py index c24adafa..c1a62143 100644 --- a/ahk/mouse.py +++ b/ahk/mouse.py @@ -18,11 +18,14 @@ def mouse_speed(self): else: return self._mouse_speed - @staticmethod - def _mouse_position(): - return make_script(''' + + def _mouse_position(self, mode=None): + if mode is None: + mode = self.mode + return make_script(f''' + CoordMode, Mouse, {mode} MouseGetPos, xpos, ypos - s .= Format("({}, {})", xpos, ypos) + s .= Format("({{}}, {{}})", xpos, ypos) FileAppend, %s%, * ''') From a0b9b005c3480a926652aff4f51c9927ab09e240 Mon Sep 17 00:00:00 2001 From: Spencer Young Date: Mon, 26 Nov 2018 19:52:49 -0800 Subject: [PATCH 05/10] add appveyor yml --- appveyor.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 appveyor.yml diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 00000000..d54c6a77 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,14 @@ +version: '1.0.{build}' + +environment: + AHK_PATH: "C:\ahk" + +install: + - appveyor DownloadFile https://github.com/AutoHotkey/AutoHotkey/releases/download/v1.0.48.05/AutoHotkey104805_Install.exe -FileName=ahk_install.exe + - .\autohotkey_install.exe /S /D=C:\ahk + - C:\python37\python.exe -m pip install . + +build: off + +test_script: + - echo "Hello" From d7768dee189d98c73fe516d99beb03c5963d7045 Mon Sep 17 00:00:00 2001 From: Spencer Young Date: Mon, 26 Nov 2018 20:04:38 -0800 Subject: [PATCH 06/10] fix DownloadFile argument --- appveyor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appveyor.yml b/appveyor.yml index d54c6a77..a2c910b7 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -4,7 +4,7 @@ environment: AHK_PATH: "C:\ahk" install: - - appveyor DownloadFile https://github.com/AutoHotkey/AutoHotkey/releases/download/v1.0.48.05/AutoHotkey104805_Install.exe -FileName=ahk_install.exe + - appveyor DownloadFile https://github.com/AutoHotkey/AutoHotkey/releases/download/v1.0.48.05/AutoHotkey104805_Install.exe -FileName ahk_install.exe - .\autohotkey_install.exe /S /D=C:\ahk - C:\python37\python.exe -m pip install . From 66e41a68ac221a5db8bcf1e8568b8b90ad6706a6 Mon Sep 17 00:00:00 2001 From: Spencer Young Date: Mon, 26 Nov 2018 20:05:21 -0800 Subject: [PATCH 07/10] more robust options for always_on_top; raise for invalid values --- ahk/window.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ahk/window.py b/ahk/window.py index c77373b8..99fad06a 100644 --- a/ahk/window.py +++ b/ahk/window.py @@ -97,12 +97,14 @@ def always_on_top(self): @always_on_top.setter def always_on_top(self, value): - if value in ('On', True, 1): + if value in ('on', 'On', True, 1): self.win_set('AlwaysOnTop', 'On') - elif value in ('Off', False, 0): + elif value in ('off', 'Off', False, 0): self.win_set('AlwaysOnTop', 'Off') - elif value == 'Toggle': + elif value in ('toggle', 'Toggle', -1): self.win_set('AlwaysOnTop', 'Toggle') + else: + raise ValueError(f'"{value}" not a valid option. Please use On/Off/Toggle/True/False/0/1/-1') def _close(self, seconds_to_wait=''): return make_script(f'''\ From bc953e9ffce3c5c3b095882652be0ed833160a6f Mon Sep 17 00:00:00 2001 From: Spencer Phillip Young Date: Tue, 27 Nov 2018 00:40:04 -0800 Subject: [PATCH 08/10] basic test and ci setup (#1) * use python37 * add scripts, tests * use wheel * use build script * move coveralls to runtests --- appveyor.yml | 20 ++++++++++++------- ci/build.ps1 | 3 +++ ci/ci_requirements.txt | 5 +++++ ci/install.ps1 | 4 ++++ ci/runtests.ps1 | 4 ++++ setup.py | 13 +++++++++---- tests/features/mouse_move.feature | 8 ++++++++ tests/features/steps/ahk_steps.py | 32 +++++++++++++++++++++++++++++++ 8 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 ci/build.ps1 create mode 100644 ci/ci_requirements.txt create mode 100644 ci/install.ps1 create mode 100644 ci/runtests.ps1 create mode 100644 tests/features/mouse_move.feature create mode 100644 tests/features/steps/ahk_steps.py diff --git a/appveyor.yml b/appveyor.yml index a2c910b7..8a8318ab 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,14 +1,20 @@ -version: '1.0.{build}' +version: '0.1.{build}' environment: - AHK_PATH: "C:\ahk" + AHK_PATH: C:\ahk\AutoHotkey.exe + AHK_DEBUG: true install: - - appveyor DownloadFile https://github.com/AutoHotkey/AutoHotkey/releases/download/v1.0.48.05/AutoHotkey104805_Install.exe -FileName ahk_install.exe - - .\autohotkey_install.exe /S /D=C:\ahk - - C:\python37\python.exe -m pip install . + - appveyor DownloadFile https://github.com/Lexikos/AutoHotkey_L/releases/download/v1.1.30.01/AutoHotkey_1.1.30.01_setup.exe -FileName ahk_install.exe + - ahk_install.exe /S /D=C:\ahk + - powershell .\ci\install.ps1 -build: off +build_script: + - powershell .\ci\build.ps1 + +artifacts: + - name: dist + path: dist\* test_script: - - echo "Hello" + - powershell .\ci\runtests.ps1 \ No newline at end of file diff --git a/ci/build.ps1 b/ci/build.ps1 new file mode 100644 index 00000000..25548914 --- /dev/null +++ b/ci/build.ps1 @@ -0,0 +1,3 @@ +.\venv\Scripts\activate.ps1 +python setup.py sdist bdist_wheel +if ($LastExitCode -ne 0) { throw } \ No newline at end of file diff --git a/ci/ci_requirements.txt b/ci/ci_requirements.txt new file mode 100644 index 00000000..862c640c --- /dev/null +++ b/ci/ci_requirements.txt @@ -0,0 +1,5 @@ +pyautogui +behave +behave-classy +coveralls +wheel \ No newline at end of file diff --git a/ci/install.ps1 b/ci/install.ps1 new file mode 100644 index 00000000..71a4653b --- /dev/null +++ b/ci/install.ps1 @@ -0,0 +1,4 @@ +py -3.7 -m venv venv +.\venv\Scripts\activate.ps1 +python -m pip install -r .\ci\ci_requirements.txt +if ($LastExitCode -ne 0) { throw } \ No newline at end of file diff --git a/ci/runtests.ps1 b/ci/runtests.ps1 new file mode 100644 index 00000000..579d6876 --- /dev/null +++ b/ci/runtests.ps1 @@ -0,0 +1,4 @@ +.\venv\Scripts\activate.ps1 +coverage run -m behave .\tests\features +if ($LastExitCode -ne 0) { throw } +coveralls \ No newline at end of file diff --git a/setup.py b/setup.py index 5de364cc..30185e53 100644 --- a/setup.py +++ b/setup.py @@ -1,12 +1,17 @@ from setuptools import setup +test_requirements = ['pyautogui', 'behave', 'behave-classy', 'coverage'] +extras = {'test': test_requirements} + setup( name='ahk', - version='0.0.1', + version='0.1.1', url='https://github.com/spyoungtech/ahk', - description='A (POC) Python wrapper for AHK', + description='A Python wrapper for AHK', author_email='spencer.young@spyoung.com', author='Spencer Young', - packages=['ahk'] -) \ No newline at end of file + packages=['ahk'], + install_requires=[], + tests_require=test_requirements, +) diff --git a/tests/features/mouse_move.feature b/tests/features/mouse_move.feature new file mode 100644 index 00000000..eef7584c --- /dev/null +++ b/tests/features/mouse_move.feature @@ -0,0 +1,8 @@ +# Acceptance test of mouse features +Feature: Mouse functionality + + Scenario: Moving the mouse + Given the mouse position is (100, 100) + When I move the mouse DOWN 100px + Then I expect the mouse position to be (100, 200) + diff --git a/tests/features/steps/ahk_steps.py b/tests/features/steps/ahk_steps.py new file mode 100644 index 00000000..f088d723 --- /dev/null +++ b/tests/features/steps/ahk_steps.py @@ -0,0 +1,32 @@ +import pyautogui +from behave.matchers import RegexMatcher +from ahk import AHK +from behave_classy import step_impl_base + +Base = step_impl_base() + + +class AHKSteps(AHK, Base): + @Base.given(u'the mouse position is ({xpos:d}, {ypos:d})') + def given_mouse_move(self, xpos, ypos): + self.mouse_move(x=xpos, y=ypos) + + @Base.when(u'I move the mouse (UP|DOWN|LEFT|RIGHT) (\d+)px', matcher=RegexMatcher) + def move_direction(self, direction, px): + px = int(px) + if direction in ('UP', 'DOWN'): + axis = 'y' + else: + axis = 'x' + if direction in ('LEFT', 'UP'): + px = px * -1 + kwargs = {axis: px, 'relative': True} + self.mouse_move(**kwargs) + + @Base.then(u'I expect the mouse position to be ({xpos:d}, {ypos:d})') + def check_position(self, xpos, ypos): + x, y = self.mouse_position + assert x == xpos + assert y == ypos + +AHKSteps().register() \ No newline at end of file From 6417aef455faf81b20666bd0d7d6b23afca74bce Mon Sep 17 00:00:00 2001 From: Spencer Young Date: Tue, 27 Nov 2018 01:27:42 -0800 Subject: [PATCH 09/10] allow returning raw bytes from run_script --- ahk/script.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ahk/script.py b/ahk/script.py index 7235cdd1..36850112 100644 --- a/ahk/script.py +++ b/ahk/script.py @@ -22,10 +22,14 @@ def __init__(self, executable_path: str='', keep_scripts: bool=False, **kwargs): def _run_script(self, script_path, **kwargs): runargs = [self.executable_path, script_path] + decode = kwargs.pop('decode', False) result = subprocess.run(runargs, stdin=None, stderr=None, stdout=subprocess.PIPE, **kwargs) - return result.stdout.decode() + if decode: + return result.stdout.decode() + else: + return result.stdout - def run_script(self, script_text: str, delete=None, **runkwargs): + def run_script(self, script_text: str, delete=None, decode=True, **runkwargs): if delete is None: delete = not self.keep_scripts with NamedTemporaryFile(mode='w', delete=False, newline='\r\n') as temp_script: @@ -33,7 +37,7 @@ def run_script(self, script_text: str, delete=None, **runkwargs): logger.debug('Script location: %s', temp_script.name) logger.debug('Script text: \n%s', script_text) try: - result = self._run_script(temp_script.name, **runkwargs) + result = self._run_script(temp_script.name, decode=decode, **runkwargs) except Exception as e: logger.fatal('Error running temp script: %s', e) raise From 02b3244661fcbbe66303c3d86185c13397aeaa70 Mon Sep 17 00:00:00 2001 From: Spencer Young Date: Tue, 27 Nov 2018 01:30:44 -0800 Subject: [PATCH 10/10] add window listing --- ahk/window.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/ahk/window.py b/ahk/window.py index 99fad06a..a02c5389 100644 --- a/ahk/window.py +++ b/ahk/window.py @@ -1,6 +1,7 @@ from ahk.script import ScriptEngine from ahk.utils import make_script import ast +from .utils import logger class WindowNotFoundError(ValueError): @@ -18,6 +19,12 @@ def __init__(self, engine, title='', text='', exclude_title='', exclude_text='', self._exclude_text = exclude_text self.match_mode = match_mode + + + @classmethod + def from_ahk_id(cls, engine, ahk_id): + raise NotImplemented + def _win_set(self, subcommand, value): script = make_script(f'''\ WinSet, {subcommand}, {value}, {self.title}, {self.text, self._exclude_title, self._exclude_text} @@ -139,9 +146,36 @@ def win_set(self, subcommand, value, **windowkwargs): win.win_set(subcommand, value) return win + def _win_title_from_ahk_id(self, ahk_id): + pass + + def _all_window_titles(self): + script = make_script('''\ + WinGet windows, List + Loop %windows% + { + id := windows%A_Index% + WinGetTitle wt, ahk_id %id% + r .= wt . "`n" + } + FileAppend, %r%, * + ''') + + resp = self.run_script(script, decode=False) + titles = [] + for title_bytes in resp.split(bytes('\n', 'ascii')): + if not title_bytes.strip(): + continue + try: + titles.append(title_bytes.decode()) + except UnicodeDecodeError as e: + logger.exception('Could not decode title; %s', str(e)) + + return titles + def windows(self): """ Return a list of all windows :return: """ - raise NotImplemented + return [self.win_get(title=title) for title in self._all_window_titles()] \ No newline at end of file