# ahkUnwrapped
I wanted to automate Windows with the coverage and simplicity of the _complete_ [AutoHotkey API](https://www.autohotkey.com/), yet code in Python, so I created `ahkUnwrapped`.

AutoHotkey already abstracts the Windows API, so another layer to introduce complexity and slowdowns is undesirable. 

Instead, we bundle and bridge *AutoHotkey.exe*, sending your initial script [via stdin](https://www.autohotkey.com/docs/AHKL_ChangeLog.htm#v1.1.17.00) with minimal boilerplate to listen for [window messages](https://www.autohotkey.com/docs/commands/OnMessage.htm) from Python and respond [via stdout](https://docs.python.org/3.7/library/subprocess.html).

## Features
* **All** of AutoHotkey!
* Execute arbitrary AHK code or load scripts.
* [Hypothesis](https://hypothesis.readthedocs.io/en/latest/) powered testing of convoluted unicode, et al.
* Warnings for loss of precision (maximum 6 decimal places).
* Errors for unsupported values (`NaN` `Inf` `\0`).
* Unhandled AHK exceptions carry over to Python.
* Won't explode when used from multiple threads.
* Separate auto-execute sections to ease scripting.
* Supports [PyInstaller](https://www.pyinstaller.org/) for _onefile/onedir_ installations.
* Special care for:
  * Descriptive errors with accurate line numbers.
  * Persistent _Windows notification area_ settings.
  * Unexpected exit handling.
  * Minimal latency.

## Get started
`> pip install ahkunwrapped`

`call(proc, ...)` `f(func, ...)` `get(var)` `set(var, val)`  

```python
from ahkunwrapped import Script

ahk = Script()
# built-in functions are directly callable
isNotepadActive = ahk.f('WinActive', 'ahk_class Notepad')
# built-in variables (and user globals) can be set directly
ahk.set('Clipboard', "Copied text!")
print(isNotepadActive)
```
---
```python
from ahkunwrapped import Script

ahk = Script('''
LuckyMinimize(winTitle) {
  global myVar
  myVar := 7
  
  WinMinimize, % winTitle
  Clipboard := "You minimized: " winTitle
}
''')

ahk.call('LuckyMinimize', 'ahk_class Notepad')
print("Lucky number", ahk.get('myVar'))
```
---
```python
from pathlib import Path
from ahkunwrapped import Script

ahk = Script.from_file(Path('my_msg.ahk'))
ahk.call('MyMsg', "Wooo!")
```

_my_msg.ahk:_
```autohotkey
; auto-execute section when ran standalone
#SingleInstance force
#Warn
AutoExec()                   ; we can call this if we want
MyMsg("test our function")
return

; auto-execute section when ran from Python
AutoExec() {
  SetBatchLines, 100ms       ; slow our code to reduce CPU
}

MyMsg(text) {
  MsgBox, % text
}
```

Settings from [AutoExec()](https://www.autohotkey.com/docs/Scripts.htm#auto) will [still apply](https://www.autohotkey.com/docs/commands/OnMessage.htm#Remarks) even though we execute directly from the message listening thread for speed.  
<sub>(AutoHotkey's [#Warn](https://www.autohotkey.com/docs/commands/_Warn.htm#Remarks) is special and will apply to both standalone and from-Python execution, unless you add/remove it dynamically.)</sub>

## Usage
`call(proc, ...)` is for performance, to avoid receiving a large unneeded result.  

`get(var)` `set(var, val)` are shorthand for accessing global variables and [built-ins](https://www.autohotkey.com/docs/Variables.htm#BuiltIn) like `A_TimeIdle`.  

`f(func, ...)` `get(var)` will infer `float` and `int` (base-16 beginning with `0x`) like AutoHotkey.  

`f_raw(func, ...)` `get_raw(var)` will return the raw string as-stored.

`call_main(proc, ...)` `f_main(func, ...)` `f_raw_main(func, ...)` will execute on AutoHotkey's main thread instead of the [OnMessage()](https://www.autohotkey.com/docs/commands/OnMessage.htm#Remarks) listener.
This avoids `AhkCantCallOutInInputSyncCallError`, e.g. from some uses of [ComObjCreate()](https://www.autohotkey.com/docs/commands/ComObjCreate.htm).
This is slower (except with very large data), but still fast and unlikely to bottleneck.

## Event loop with hotkeys

```python
import sys
import time
from datetime import datetime
from enum import Enum
from pathlib import Path

from ahkunwrapped import Script, AhkExitException

choice = None
HOTKEY_SEND_CHOICE = 'F2'


class Event(Enum):
  QUIT, SEND_CHOICE, CLEAR_CHOICE, CHOOSE_MONTH, CHOOSE_DAY = range(5)


# format_dict= so we can use {{VARIABLE}} within example.ahk
ahk = Script.from_file(Path('example.ahk'), format_dict=globals())


def main() -> None:
  print("Scroll your mousewheel in Notepad.")

  ts = 0
  while True:
    try:
      # ahk.poll()  # detect exit, but all ahk functions include this

      s_elapsed = time.time() - ts
      if s_elapsed >= 60:
        ts = time.time()
        print_minute()

      event = ahk.get('event')  # contains ahk.poll()
      if event:
        ahk.set('event', '')
        on_event(event)
    except AhkExitException as ex:
      sys.exit(ex.args[0])
    time.sleep(0.01)


def print_minute() -> None:
  print(f"It is now {datetime.now().time()}")


def on_event(event: str) -> None:
  global choice

  def get_choice() -> str:
    return choice or datetime.now().strftime('%#I:%M %p')

  if event == str(Event.QUIT):
    ahk.exit()
  if event == str(Event.CLEAR_CHOICE):
    choice = None
  if event == str(Event.SEND_CHOICE):
    ahk.call('Send', f'{get_choice()} ')
  if event == str(Event.CHOOSE_MONTH):
    choice = datetime.now().strftime('%b')
    ahk.call('ToolTip', f"Month is {get_choice()}, {HOTKEY_SEND_CHOICE} to insert.")
  if event == str(Event.CHOOSE_DAY):
    choice = datetime.now().strftime('%#d')
    ahk.call('ToolTip', f"Day is {get_choice()}, {HOTKEY_SEND_CHOICE} to insert.")


if __name__ == '__main__':
  main()
```

_example.ahk:_
```autohotkey
#SingleInstance, force
#Warn
ToolTip("Standalone script test!")
return

AutoExec() {
    global event
    event := ""
    SendMode, input
}

Send(text) {
    Send, % text
}

ToolTip(text, s := 2) {
    ToolTip, % text
    ; negative for non-repeating
    SetTimer, RemoveToolTip, % s * -1000
}

RemoveToolTip:
    ToolTip,
    event = {{Event.CLEAR_CHOICE}}
return

MouseIsOver(winTitle) {
    MouseGetPos,,, winId
    result := WinExist(winTitle " ahk_id " winId)
    return result
}

#If WinActive("ahk_class Notepad")
{{HOTKEY_SEND_CHOICE}}::event = {{Event.SEND_CHOICE}}
^Q::event = {{Event.QUIT}}
#If MouseIsOver("ahk_class Notepad")
WheelUp::event = {{Event.CHOOSE_MONTH}}
WheelDown::event = {{Event.CHOOSE_DAY}}
```

## PyInstaller (single _.exe_ or folder)
_example.[spec](https://pyinstaller.readthedocs.io/en/stable/spec-files.html):_

```python
# -*- mode: python -*-
from pathlib import Path

import ahkunwrapped

a = Analysis(
  ['example.py'],
  datas=[
    (Path(ahkunwrapped.__file__).parent / 'lib', 'lib'),  # required
    ('example.ahk', '.'),
  ]
)
pyz = PYZ(a.pure)

# for onefile
exe = EXE(pyz, a.scripts, a.binaries, a.datas, name='my-example', upx=True, console=False)

# for onedir
# exe = EXE(pyz, a.scripts, exclude_binaries=True, name='my-example', upx=True, console=False)
# dir = COLLECT(exe, a.binaries, a.datas, name='my-example-folder')
```

### Folder considerations

_example.py:_
```python
from pathlib import Path

from ahkunwrapped import Script

# tray icon visibility settings rely on consistent exe paths
LOCALAPP_DIR = Path(os.getenv('LOCALAPPDATA') / 'pyinstaller-example')

# working directory is different between onefile and onedir modes
#  https://pyinstaller.readthedocs.io/en/stable/runtime-information.html
CUR_DIR = Path(getattr(sys, '_MEIPASS', Path(__file__).parent))

ahk = Script.from_file(CUR_DIR / 'example.ahk', format_dict=globals(), execute_from=LOCALAPP_DIR)

# ...
```

_example.ahk:_
```autohotkey
AutoExec() {
  Menu, Tray, Icon, {{CUR_DIR}}\black.ico
  Menu, Tray, Icon  ; unhide
}

; ...
```