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 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..c1a62143 --- /dev/null +++ b/ahk/mouse.py @@ -0,0 +1,69 @@ +from ahk.utils import make_script +from ahk.script import ScriptEngine +import ast + + +class MouseMixin(ScriptEngine): + 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) + + @property + def mouse_speed(self): + if callable(self._mouse_speed): + return self._mouse_speed() + else: + return self._mouse_speed + + + 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) + 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, 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 + 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''' + CoordMode Mouse, {mode} + 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..36850112 --- /dev/null +++ b/ahk/script.py @@ -0,0 +1,49 @@ +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] + decode = kwargs.pop('decode', False) + result = subprocess.run(runargs, stdin=None, stderr=None, stdout=subprocess.PIPE, **kwargs) + if decode: + return result.stdout.decode() + else: + return result.stdout + + 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: + 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, decode=decode, **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..018b08a6 --- /dev/null +++ b/ahk/utils.py @@ -0,0 +1,42 @@ +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): + """ + 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..a02c5389 --- /dev/null +++ b/ahk/window.py @@ -0,0 +1,181 @@ +from ahk.script import ScriptEngine +from ahk.utils import make_script +import ast +from .utils import logger + + +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 + + + + @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} + ''') + 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', 'On', True, 1): + self.win_set('AlwaysOnTop', 'On') + elif value in ('off', 'Off', False, 0): + self.win_set('AlwaysOnTop', 'Off') + 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'''\ + 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 _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: + """ + return [self.win_get(title=title) for title in self._all_window_titles()] \ No newline at end of file diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 00000000..8a8318ab --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,20 @@ +version: '0.1.{build}' + +environment: + AHK_PATH: C:\ahk\AutoHotkey.exe + AHK_DEBUG: true + +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_script: + - powershell .\ci\build.ps1 + +artifacts: + - name: dist + path: dist\* + +test_script: + - 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