Skip to content

Commit 84e48e4

Browse files
authored
Merge pull request #372 from spyoungtech/contribution-guide
contribution guide
2 parents 3d66b7a + ecb6c51 commit 84e48e4

File tree

3 files changed

+286
-3
lines changed

3 files changed

+286
-3
lines changed

.github/workflows/test.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@ jobs:
2020
python -m pip install --upgrade pip
2121
python -m pip install -r requirements-dev.txt
2222
python -m pip install .
23-
python -m pip install tox
24-
python -m pip install "ahk-binary==2023.9.0"
2523
- name: Test with coverage/pytest
2624
timeout-minutes: 10
2725
env:

CONTRIBUTING.md

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
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.

requirements-dev.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
pytest
22
pillow
3-
unasync
3+
unasync@https://github.com/spyoungtech/unasync/archive/refs/heads/unasync-remove.zip
44
black
55
tokenize-rt
66
coverage
@@ -9,3 +9,6 @@ typing_extensions
99
jinja2
1010
pytest-rerunfailures
1111
ahk-json
12+
ahk-binary
13+
pre-commit
14+
tox

0 commit comments

Comments
 (0)