|
| 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 | +<!-- TOC --> |
| 9 | +* [Contribution Guide](#contribution-guide) |
| 10 | +* [Before contributing](#before-contributing) |
| 11 | +* [Initial development setup](#initial-development-setup) |
| 12 | + * [Code formatting, linting, etc.](#code-formatting-linting-etc) |
| 13 | +* [Unasync Code Generation](#unasync-code-generation) |
| 14 | +* [Pre-commit hooks](#pre-commit-hooks) |
| 15 | +* [Running tests](#running-tests) |
| 16 | +* [How this project works, briefly](#how-this-project-works-briefly) |
| 17 | + * [Hotkeys](#hotkeys) |
| 18 | +* [Example: Implementing a new method](#example-implementing-a-new-method) |
| 19 | + * [Writing the AutoHotkey code](#writing-the-autohotkey-code) |
| 20 | + * [Writing the Python code](#writing-the-python-code) |
| 21 | + * [Testing and code generation](#testing-and-code-generation) |
| 22 | +* [About your contributions :balance_scale:](#about-your-contributions-balance_scale) |
| 23 | +<!-- TOC --> |
| 24 | + |
| 25 | + |
| 26 | +# Before contributing |
| 27 | + |
| 28 | +Generally, all contributions should be associated with an [open issue](https://github.com/spyoungtech/ahk/issues). |
| 29 | +Contributors are strongly encouraged to comment on an existing issue or create a new issue before working on a PR, |
| 30 | +especially for feature work. Some contributions don't necessarily require this, such as typo fixes or documentation |
| 31 | +improvements. When in doubt, create an issue. |
| 32 | + |
| 33 | + |
| 34 | + |
| 35 | +# Initial development setup |
| 36 | + |
| 37 | +Some prerequisite steps are needed to get ready for development on this project: |
| 38 | + |
| 39 | +- Activated virtualenv with Python version 3.9 or later (`py -m venv venv` and `venv\Scripts\activate`) |
| 40 | +- Installed the dev requirements (`pip install -r requirements-dev.txt`) (this includes a binary redistribution of AutoHotkey) |
| 41 | +- Installed pre-commit hooks (`pre-commit install`) |
| 42 | + |
| 43 | +That's it! |
| 44 | + |
| 45 | +## Code formatting, linting, etc. |
| 46 | + |
| 47 | +All matters of code style, linting, etc. are all handled by pre-commit hooks. All the proper parameters for formatting |
| 48 | +and correct order of operations are provided there. If you try to run `black` or similar formatters directly on the |
| 49 | +project, it will likely produce a lot of unintended changes that will not be accepted. |
| 50 | + |
| 51 | +For these reasons and more, it is critical that you use the `pre-commit` hooks in order to make a successful contribution. |
| 52 | + |
| 53 | + |
| 54 | +# Unasync Code Generation |
| 55 | + |
| 56 | +This project leverages a [fork](https://github.com/spyoungtech/unasync/tree/unasync-remove) of [`unasync`](https://github.com/python-trio/unasync) |
| 57 | +to automatically generate synchronous code (output to the `ahk/_sync` directory) from async code in the `ahk/_async` directory. |
| 58 | + |
| 59 | +To be clear: **you will _never_ need to write code directly in the `ahk/_sync` directory**. This is all auto-generated code. |
| 60 | + |
| 61 | +Code generation runs as part of the pre-commit hooks. |
| 62 | + |
| 63 | + |
| 64 | +# Pre-commit hooks |
| 65 | + |
| 66 | +Pre-commit hooks are an essential part of development for this project. They will ensure your code is properly formatted |
| 67 | +and linted. It is also essential for performing code generation, as discussed in the previous section. |
| 68 | + |
| 69 | +To run the pre-commit hooks: |
| 70 | + |
| 71 | +```bash |
| 72 | +pre-commit run --all-files |
| 73 | +``` |
| 74 | + |
| 75 | + |
| 76 | +# Running tests |
| 77 | + |
| 78 | +The test suite is managed by [`tox`](https://tox.wiki/en/latest/) (installed as part of `requirements-dev`) |
| 79 | + |
| 80 | +You can run the test suite with the following command: |
| 81 | + |
| 82 | +```bash |
| 83 | +tox -e py |
| 84 | +``` |
| 85 | + |
| 86 | +Tox runs tests in an isolated environment. |
| 87 | + |
| 88 | +Although `tox` is the recommended way of testing, with all dev requirements installed, |
| 89 | +you can run the tests directly with `pytest` (but be sure to run code generation first!): |
| 90 | + |
| 91 | +```bash |
| 92 | +pytest tests |
| 93 | +``` |
| 94 | + |
| 95 | +Notes: |
| 96 | + |
| 97 | +- 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 |
| 98 | +- 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 |
| 99 | +- Due to the nature of this library, the test suite takes a long time to run |
| 100 | +- Some tests (which only run locally, not in CI) for pixelsearch/imagesearch may fail depending on your monitor settings. This can safely be ignored. |
| 101 | +- Some tests are flaky -- the tox configuration adds appropriate reruns to pytest to compensate for this, but reruns are not always 100% effective |
| 102 | +- You can also simply rely on the GitHub Actions workflows for running tests |
| 103 | + |
| 104 | + |
| 105 | +# How this project works, briefly |
| 106 | + |
| 107 | +Understanding how this project works under the hood is an important part to contributing. Here, we'll graze over the |
| 108 | +most important implementation details, but contributors are encouraged to dive into the source code to learn more |
| 109 | +and always feel free to open an issue or discussion to ask questions. |
| 110 | + |
| 111 | +This project is a wrapper around AutoHotkey. That is: it does not directly implement the underlying functionality, but |
| 112 | +instead relies directly on AutoHotkey itself to function; specifically, AutoHotkey is invoked as a subprocess. |
| 113 | + |
| 114 | +In typical usage, an AutoHotkey subprocess is created and runs the "daemon" script (found in `ahk/templates/`). The |
| 115 | +[Auto-Execute section](https://www.autohotkey.com/docs/v2/Scripts.htm#auto) of which is an infinite loop that awaits |
| 116 | +inputs via `stdin` to execute functions and return responses. The request and response formats are specialized. |
| 117 | + |
| 118 | +A typical function call (like, say, `ahk.mouse_move`) works roughly like this: |
| 119 | + |
| 120 | +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. |
| 121 | +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) |
| 122 | +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) |
| 123 | +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`. |
| 124 | +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 |
| 125 | +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 |
| 126 | + |
| 127 | + |
| 128 | +Technically, a subprocess is only one possible transport. Although it is the only one implemented directly in this library, |
| 129 | +alternate transports can be used, such as in the [ahk-client](https://github.com/spyoungtech/ahk-client) project, which implements |
| 130 | +AHK function calls over HTTP (to a server running [ahk-server](https://github.com/spyoungtech/ahk-server)). |
| 131 | + |
| 132 | + |
| 133 | +## Hotkeys |
| 134 | + |
| 135 | +Hotkeys work slightly different from typical functions. Hotkeys are powered by a separate subprocess, which is started |
| 136 | +with the `start_hotkeys` method. This subprocess runs the hotkeys script (e.g. `ahk/templates/hotkeys-v2.ahk`). This works |
| 137 | +like a normal AutoHotkey script and when hotkeys are triggered, they write to `stdout`. A Python thread reads |
| 138 | +from `stdout` and triggers the registered hotkey function. Unlike normal functions found in `ahk/_async`, the implementation of hotkeys |
| 139 | +(found in `ahk/hotkeys.py`) is not implemented async-first -- it is all synchronous/threaded Python. |
| 140 | + |
| 141 | + |
| 142 | +# Example: Implementing a new method |
| 143 | + |
| 144 | +This section will guide you through the steps of implementing a basic new feature. This is very closely related to the |
| 145 | +documented process of [writing an extension](https://ahk.readthedocs.io/en/latest/extending.html), except that you are |
| 146 | +including the functionality directly in the project, rather than using the extensions interface. It is highly |
| 147 | +recommended that you read the extension docs! |
| 148 | + |
| 149 | +This involves three basic steps: |
| 150 | + |
| 151 | +1. Writing the AutoHotkey function(s) -- for both v1 and v2 |
| 152 | +2. Writing the (async) Python method(s) |
| 153 | +3. Generating the sync code and testing (which implies writing tests at some point!) |
| 154 | + |
| 155 | + |
| 156 | +In this example, we'll add a simple method that simply calls into AHK to do some arithemetic. Normally, |
| 157 | +such a method wouldn't be prudent to implement in this library since Python can obviously handle arithmetic without |
| 158 | +AutoHotkey, but we'll ignore this just for the sake of the example. |
| 159 | + |
| 160 | +It is recommended, but not required, that you start by checking out a new branch named after the GitHub issue number |
| 161 | +you're working on in the format `gh-<issue-number>` e.g.: |
| 162 | + |
| 163 | +```bash |
| 164 | +git checkout -b gh-12345 |
| 165 | +``` |
| 166 | + |
| 167 | + |
| 168 | +## Writing the AutoHotkey code |
| 169 | + |
| 170 | +For example, in `ahk/templates/daemon-v2.ahk`, you may add a new function as so: |
| 171 | + |
| 172 | +```AutoHotkey |
| 173 | +AHKSimpleMath(lhs, rhs, operator) { |
| 174 | + if (operator = "+") { |
| 175 | + result := (lhs + rhs) |
| 176 | + } else if (operator = "*") { |
| 177 | + result := (lhs * rhs) |
| 178 | + } else { ; invalid operator argument |
| 179 | + return FormatResponse("ahk.message.ExceptionResponseMessage", Format("Invalid operator: {}", operator)) |
| 180 | + } |
| 181 | + return FormatResponse("ahk.message.IntegerResponseMessage", result) |
| 182 | +} |
| 183 | +``` |
| 184 | + |
| 185 | +And you would add the same to `ahk/templates/daemon.ahk` for AHK V1. |
| 186 | + |
| 187 | +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) |
| 188 | +for more information about available message formats and implementing new message formats. |
| 189 | + |
| 190 | + |
| 191 | +## Writing the Python code |
| 192 | + |
| 193 | + |
| 194 | +For example, in `ahk/_async/engine.py` you might add the following method to the `AsyncAHK` class: |
| 195 | + |
| 196 | +```python |
| 197 | +async def simple_math(self, lhs: int, rhs: int, operator: Literal['+', '*']) -> int: |
| 198 | + """ |
| 199 | + Exposes arithmetic functions in AutoHotkey for plus and times operators |
| 200 | + """ |
| 201 | + assert isinstance(lhs, int) |
| 202 | + assert isinstance(rhs, int) |
| 203 | + |
| 204 | + # Normally, you probably want to validate all inputs, but we'll comment this out to demo bubbling up AHK exceptions |
| 205 | + # assert operator in ('+', '*') |
| 206 | + |
| 207 | + args = [str(lhs), str(rhs), operator] # all args must be strings |
| 208 | + result = await self._transport.function_call('AHKSimpleMath', args, blocking=True) |
| 209 | + return result |
| 210 | +``` |
| 211 | + |
| 212 | + |
| 213 | +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", ...)`. |
| 214 | + |
| 215 | +:warning: For functions that accept the `blocking` keyword argument, it is important that no further manipulation be done on the value returned |
| 216 | +(since it can be a _future_ result and not the ultimate value). If additional processing of the return value is needed, it |
| 217 | +should be implemented in the message type instead. |
| 218 | + |
| 219 | + |
| 220 | +## Testing and code generation |
| 221 | + |
| 222 | +In `tests/_async` create a new testcase in a new file like `tests/_async/test_math.py` with some basic test cases |
| 223 | +that cover a range of possible inputs and expected exceptional cases: |
| 224 | + |
| 225 | +```python |
| 226 | +import unittest |
| 227 | + |
| 228 | +import pytest |
| 229 | + |
| 230 | +from ahk import AsyncAHK |
| 231 | + |
| 232 | +class MathTestCases(unittest.IsolatedAsyncioTestCase): |
| 233 | + async def test_simple_math_plus_operator(self): |
| 234 | + ahk = AsyncAHK() |
| 235 | + result = await ahk.simple_math(1, 2, '+') |
| 236 | + expected = 3 |
| 237 | + assert result == expected |
| 238 | + |
| 239 | + async def test_simple_math_times_operator(self): |
| 240 | + ahk = AsyncAHK() |
| 241 | + result = await ahk.simple_math(2, 3, '*') |
| 242 | + expected = 6 |
| 243 | + assert result == expected |
| 244 | + |
| 245 | + async def test_simple_math_bad_operator(self): |
| 246 | + ahk = AsyncAHK() |
| 247 | + with pytest.raises(Exception) as exc_info: |
| 248 | + await ahk.simple_math(1, 2, '>>>') |
| 249 | + assert "Invalid operator:" in str(exc_info.value) |
| 250 | +``` |
| 251 | + |
| 252 | +Finally, run the `pre-commit` hooks to generate the synchronous code (both for your implementation and your tests) |
| 253 | + |
| 254 | +```bash |
| 255 | +pre-commit run --all-files |
| 256 | +``` |
| 257 | + |
| 258 | +You'll notice that the `ahk/_sync` directory and the `tests/_sync` directories now contain the synchronous |
| 259 | +versions of your implementation code and your tests, respectively. |
| 260 | + |
| 261 | +And then run the tests: |
| 262 | + |
| 263 | +```bash |
| 264 | +tox -e py |
| 265 | +``` |
| 266 | + |
| 267 | +When all tests are passing, you are ready to open a pull request to get your contributions reviewed and merged. |
| 268 | + |
| 269 | +# About your contributions :balance_scale: |
| 270 | + |
| 271 | +When you submit contributions to this project, you should understand that your contributions will be licensed under |
| 272 | +the license terms of the project (found in `LICENSE`). |
| 273 | + |
| 274 | +Moreover, by submitting a pull request to this project, you are representing that the code you are contributing is your own and is |
| 275 | +unencumbered by any other licensing requirements. |
| 276 | + |
| 277 | +Do not submit unoriginal code that is either unlicensed or licensed under any other terms without stating its source and |
| 278 | +ensuring the contribution is fully compliant with any such licensing terms (which usually requires, at a minimum, |
| 279 | +including the license itself). Even when contributing work under implied, creative commons, or licenses that do not |
| 280 | +require attribution or notices (e.g. [_unlicence_](https://unlicense.org/) or similar), you are expected to explicitly |
| 281 | +state the source of any material you submit that is not your own work. This includes, for example, code snippets found |
| 282 | +on StackOverflow, the AutoHotkey forums, or any other source other than your own brain. |
0 commit comments