Skip to content

Commit a4d374d

Browse files
committed
contribution guide
1 parent 3d66b7a commit a4d374d

File tree

3 files changed

+242
-3
lines changed

3 files changed

+242
-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: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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+
```

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)