|
| 1 | +# Contribution Guide |
| 2 | + |
| 3 | +This guide is a work in progress, but aims to help a new contributor make a successful contribution to this project. |
| 4 | + |
| 5 | +If you have questions about contributing not answered here, always feel free to [open an issue](https://github.com/spyoungtech/ahk/issues) |
| 6 | +or [discussion](https://github.com/spyoungtech/ahk/discussions) and I will help you the best that I am able. |
| 7 | + |
| 8 | +## Before contributing |
| 9 | + |
| 10 | +Generally, all contributions should be associated with an [open issue](https://github.com/spyoungtech/ahk/issues). |
| 11 | +Contributors are strongly encouraged to comment on an existing issue or create a new issue before working on a PR, |
| 12 | +especially for feature work. Some contributions don't necessarily require this, such as typo fixes or documentation |
| 13 | +improvements. When in doubt, create an issue. |
| 14 | + |
| 15 | + |
| 16 | + |
| 17 | +## Initial development setup |
| 18 | + |
| 19 | +- Activated virtualenv with Python version 3.9 or later (`py -m venv venv` and `venv\Scripts\activate`) |
| 20 | +- Installed the dev requirements (`pip install -r requirements-dev.txt`) (this includes a binary redistribution of AutoHotkey) |
| 21 | +- Installed pre-commit hooks (`pre-commit install`) |
| 22 | + |
| 23 | + |
| 24 | +### Code formatting, linting, etc. |
| 25 | + |
| 26 | +All matters of code style, linting, etc. are all handled by pre-commit hooks. All the proper parameters for formatting |
| 27 | +and correct order of operations are provided there. If you try to run `black` or similar formatters directly on the |
| 28 | +project, it will likely produce a lot of unintended changes that will not be accepted. |
| 29 | + |
| 30 | +For these reasons and more, it is critical that you use the `pre-commit` hooks in order to make a successful contribution. |
| 31 | + |
| 32 | + |
| 33 | +## Running tests |
| 34 | + |
| 35 | +The test suite is managed by [`tox`](https://tox.wiki/en/latest/) (installed as part of `requirements-dev`) |
| 36 | + |
| 37 | +You can run the test suite with the following command: |
| 38 | + |
| 39 | +```bash |
| 40 | +tox -e py |
| 41 | +``` |
| 42 | + |
| 43 | +Tox runs tests in an isolated environment. |
| 44 | + |
| 45 | +Although `tox` is the recommended way of testing, with all dev requirements installed, |
| 46 | +you can run the tests directly with `pytest`: |
| 47 | + |
| 48 | +```bash |
| 49 | +pytest tests |
| 50 | +``` |
| 51 | + |
| 52 | +Notes: |
| 53 | + |
| 54 | +- The test suite expects the presence of the (legacy since Windows 11) `notepad.exe` program. This is included by default in Windows 10, but you may have to install this manually in later versions of Windows |
| 55 | +- You will pretty much need to leave your computer alone during the test suite run. Moving the mouse, typing on the keyboard, or doing much of anything will make tests fail |
| 56 | +- Due to the nature of this library, the test suite takes a long time to run |
| 57 | +- Some tests (which only run locally, not in CI) for pixelsearch/imagesearch may fail depending on your monitor settings. This can safely be ignored. |
| 58 | +- Some tests are flaky -- the tox configuration adds appropriate reruns to pytest to compensate for this, but reruns are not always 100% effective |
| 59 | +- You can also simply rely on the GitHub Actions workflows for running tests |
| 60 | + |
| 61 | +## Unasync Code Generation |
| 62 | + |
| 63 | +This project leverages a [fork](https://github.com/spyoungtech/unasync/tree/unasync-remove) of [`unasync`](https://github.com/python-trio/unasync) |
| 64 | +to automatically generate synchronous code (output to the `ahk/_sync` directory) from async code in the `ahk/_async` directory. |
| 65 | + |
| 66 | +To be clear: **you will _never_ need to write code directly in the `ahk/_sync` directory**. This is all auto-generated code. |
| 67 | + |
| 68 | +Code generation runs as part of the pre-commit hooks. |
| 69 | + |
| 70 | + |
| 71 | +## Pre-commit hooks |
| 72 | + |
| 73 | +Pre-commit hooks are an essential part of development for this project. They will ensure your code is properly formatted |
| 74 | +and linted. It is also essential for performing code generation, as discussed in the previous section. |
| 75 | + |
| 76 | +To run the pre-commit hooks: |
| 77 | + |
| 78 | +```bash |
| 79 | +pre-commit run --all-files |
| 80 | +``` |
| 81 | + |
| 82 | +## How this project works, generally |
| 83 | + |
| 84 | +This project is a wrapper around AutoHotkey. That is: it does not directly implement the underlying functionality, but |
| 85 | +instead relies directly on AutoHotkey itself to function; specifically, AutoHotkey is invoked as a subprocess. |
| 86 | + |
| 87 | +In typical usage, an AutoHotkey subprocess is created and runs the "daemon" script (found in `ahk/templates/`). The |
| 88 | +[Auto-Execute section](https://www.autohotkey.com/docs/v2/Scripts.htm#auto) of which is an infinite loop that awaits |
| 89 | +inputs via `stdin` to execute functions and return responses. The request and response formats are specialized. |
| 90 | + |
| 91 | +A typical function call (like, say, `ahk.mouse_move`) works roughly like this: |
| 92 | + |
| 93 | +0. If the AutoHotkey subprocess has not been previously started (or if the call is made with `blocking=False`), a new AutoHotkey process is created, running the daemon AHK script. |
| 94 | +1. Python takes the keyword arguments of the method (if any) and prepares them into a request message (fundamentally, a list of strings, starting with the function name followed by any arguments) |
| 95 | +2. The request is sent via `stdin` to the AutoHotkey subprocess (by implementation detail, arguments are base64 encoded and pipe-delimited and the mesage is newline-terminated) |
| 96 | +3. The AutoHotkey subprocess (which is a loop reading `stdin`) reads/decodes the message and calls the corresponding function -- All such function calls to AutoHotkey **ALWAYS** return a response, even when the return value is ultimately `None`. |
| 97 | +4. The AutoHotkey functions return a response, which is then written to `stdout` to send back to Python. The message contains information about the return type (such as a string, tuple, Exception, etc.) and the payload itself |
| 98 | +5. Python then reads the response from the subprocess's `stdout` handle, translates the response to the return value expected by the caller. Responses can also be exception types, in which case, an exception can be raised as a result of decoding the message |
| 99 | + |
| 100 | + |
| 101 | +Technically, a subprocess is only one possible transport. Although it is the only one implemented directly in this library, |
| 102 | +alternate transports can be used, such as in the [ahk-client](https://github.com/spyoungtech/ahk-client) project, which implements |
| 103 | +AHK function calls over HTTP (to a server running [ahk-server](https://github.com/spyoungtech/ahk-server)). |
| 104 | + |
| 105 | + |
| 106 | +### Hotkeys |
| 107 | + |
| 108 | +Hotkeys work slightly different from typical functions. Hotkeys are powered by a separate subprocess, which is started |
| 109 | +with the `start_hotkeys` method. This subprocess runs the hotkeys script (e.g. `ahk/templates/hotkeys-v2.ahk`). This works |
| 110 | +like a normal AutoHotkey script and when hotkeys are triggered, they write to `stdout`. A Python thread reads |
| 111 | +from `stdout` and triggers the registered hotkey function. Unlike normal functions found in `ahk/_async`, the implementation of hotkeys |
| 112 | +(found in `ahk/hotkeys.py`) is not implemented async-first -- it is all synchronous Python. |
| 113 | + |
| 114 | + |
| 115 | +## Implementing a new method |
| 116 | + |
| 117 | +This section will guide you through the steps of implementing a basic new feature. This is very closely related to the |
| 118 | +documented process of [writing an extension](https://ahk.readthedocs.io/en/latest/extending.html), except that you are |
| 119 | +including the functionality directly in the project, rather than using the extensions interface. It is highly |
| 120 | +recommended that you read the extension docs! |
| 121 | + |
| 122 | +This involves three basic steps: |
| 123 | + |
| 124 | +1. Writing the AutoHotkey function(s) -- for both v1 and v2 |
| 125 | +2. Writing the (async) Python method(s) |
| 126 | +3. Generating the sync code and testing (which implies writing tests at some point!) |
| 127 | + |
| 128 | + |
| 129 | +In this example, we'll add a simple method that simply calls into AHK to do some arithemetic. Normally, |
| 130 | +such a method wouldn't be prudent to implement in this library since Python can obviously handle arithmetic without |
| 131 | +AutoHotkey, but we'll ignore this just for the sake of the example. |
| 132 | + |
| 133 | +It is recommended, but not required, that you start by checking out a new branch named after the GitHub issue number |
| 134 | +you're working on in the format `gh-<issue-number>` e.g.: |
| 135 | + |
| 136 | +```bash |
| 137 | +git checkout -b gh-12345 |
| 138 | +``` |
| 139 | + |
| 140 | + |
| 141 | +### Writing the AutoHotkey code |
| 142 | + |
| 143 | +For example, in `ahk/templates/daemon-v2.ahk`, you may add a new function as so: |
| 144 | + |
| 145 | +```AutoHotkey |
| 146 | +AHKSimpleMath(lhs, rhs, operator) { |
| 147 | + if (operator = "+") { |
| 148 | + result := (lhs + rhs) |
| 149 | + } else if (operator = "*") { |
| 150 | + result := (lhs * rhs) |
| 151 | + } else { ; invalid operator argument |
| 152 | + return FormatResponse("ahk.message.ExceptionResponseMessage", Format("Invalid operator: {}", operator)) |
| 153 | + } |
| 154 | + return FormatResponse("ahk.message.IntegerResponseMessage", result) |
| 155 | +} |
| 156 | +``` |
| 157 | + |
| 158 | +And you would add the same to `ahk/templates/daemon.ahk` for AHK V1. |
| 159 | + |
| 160 | +Note that functions must always return a response (e.g. as provided by `FormatResponse`). Refer to the [extension guide](https://ahk.readthedocs.io/en/latest/extending.html) |
| 161 | +for more information about available message formats and implementing new message formats. |
| 162 | + |
| 163 | + |
| 164 | +### Writing the Python code |
| 165 | + |
| 166 | + |
| 167 | +For example, in `ahk/_async/engine.py` you might add the following method to the `AsyncAHK` class: |
| 168 | + |
| 169 | +```python |
| 170 | +async def simple_math(self, lhs: int, rhs: int, operator: Literal['+', '*']) -> int: |
| 171 | + """ |
| 172 | + Exposes arithmetic functions in AutoHotkey for plus and times operators |
| 173 | + """ |
| 174 | + assert isinstance(lhs, int) |
| 175 | + assert isinstance(rhs, int) |
| 176 | + |
| 177 | + # Normally, you probably want to validate all inputs, but we'll comment this out to demo bubbling up AHK exceptions |
| 178 | + # assert operator in ('+', '*') |
| 179 | + |
| 180 | + args = [str(lhs), str(rhs), operator] # all args must be strings |
| 181 | + result = await self._transport.function_call('AHKSimpleMath', args, blocking=True) |
| 182 | + return result |
| 183 | +``` |
| 184 | + |
| 185 | + |
| 186 | +The most important part of this code is that the last part of the function returns the value of `await self._transport.function_call("FUNCTION NAME", ...)`. |
| 187 | + |
| 188 | +:warning: For functions that accept the `blocking` keyword argument, it is important that no further manipulation be done on the value returned |
| 189 | +(since it can be a _future_ result and not the ultimate value). If additional processing of the return value is needed, it |
| 190 | +should be implemented in the message type instead. |
| 191 | + |
| 192 | + |
| 193 | +### Testing and code generation |
| 194 | + |
| 195 | +In `tests/_async` create a new testcase in a new file like `tests/_async/test_math.py` with some basic test cases |
| 196 | +that cover a range of possible inputs and expected exceptional cases: |
| 197 | + |
| 198 | +```python |
| 199 | +import unittest |
| 200 | + |
| 201 | +import pytest |
| 202 | + |
| 203 | +from ahk import AsyncAHK |
| 204 | + |
| 205 | +class MathTestCases(unittest.IsolatedAsyncioTestCase): |
| 206 | + async def test_simple_math_plus_operator(self): |
| 207 | + ahk = AsyncAHK() |
| 208 | + result = await ahk.simple_math(1, 2, '+') |
| 209 | + expected = 3 |
| 210 | + assert result == expected |
| 211 | + |
| 212 | + async def test_simple_math_times_operator(self): |
| 213 | + ahk = AsyncAHK() |
| 214 | + result = await ahk.simple_math(2, 3, '*') |
| 215 | + expected = 6 |
| 216 | + assert result == expected |
| 217 | + |
| 218 | + async def test_simple_math_bad_operator(self): |
| 219 | + ahk = AsyncAHK() |
| 220 | + with pytest.raises(Exception) as exc_info: |
| 221 | + await ahk.simple_math(1, 2, '>>>') |
| 222 | + assert "Invalid operator:" in str(exc_info.value) |
| 223 | +``` |
| 224 | + |
| 225 | +Finally, run the `pre-commit` hooks to generate the synchronous code (both for your implementation and your tests) |
| 226 | + |
| 227 | +```bash |
| 228 | +pre-commit run --all-files |
| 229 | +``` |
| 230 | + |
| 231 | +You'll notice that the `ahk/_sync` directory and the `tests/_sync` directories now contain the synchronous |
| 232 | +versions of your implementation code and your tests, respectively. |
| 233 | + |
| 234 | +And then run the tests: |
| 235 | + |
| 236 | +```bash |
| 237 | +tox -e py |
| 238 | +``` |
0 commit comments