- Published on
Building an Automated & Robust Workflow for Anki Add-On development
At first, developing Anki add-ons can be an absolute nightmare — especially without a proper, automated development workflow.
The final packaging process for shipping to end-users can be pretty tedious and manual if your add-on depends on external libraries or executables, as you have to manually maintain vendored versions yourself. Without static type checking and unit testing, a lot of bugs are left uncaught until Anki is launched, costing more development time.
Programming Qt UI layouts while opening and closing Anki hundreds of times to change tiny parts also becomes excruciatingly painful. Furthermore, can we simplify the process of building and running the addon into a single command? And how do we integrate a CI/CD pipeline into such a tightly coupled system?
We’re left uncertain with many unanswered questions, and unfortunately, there’s just not enough information out there that concisely answers them all in one place. In this article, I’d like to go over how to automate several aspects of the workflow, including but not limited to:
- Dependency bundling with Poetry and Invoke
- Automate packaging into
.ankiaddon
- CI/CD and pre-commit hooks
- Unit tests, static type checking, and formatting
This setup is just what has worked for me — your mileage may vary. Before proceeding, I'd like to make two notes about the rest of the article:
[src]
-> Top-level source directory name (recommended file structure)[addon-name]
-> Addon name (will be embedded in path)
Poetry Setup
Poetry is a convenient dependency management tool for Python, and we will be using it to isolate dependencies and differentiate between development and production packages. To set it up for your add-on:
poetry init
This creates a pyproject.toml
file, allowing us to start adding development dependencies:
poetry -G dev add aqt[qt5] invoke black pytest pytest-cov pre-commit markdown2 pyright
A brief overview of the packages we just introduced:
aqt
- The UI part of Anki, mostly what an Anki add-on interfaces with.invoke
- Amake
inspired Python library for organizing python code and shell subprocesses into CLI invokable tasks. Think of this as our “build system”.black
- PEP 8 compliant opinionated code formatter.pyright
- Static type checkerpytest
&pytest-cov
- Full-featured testing tool and plugin to produce coverage reportsmarkdown2
- Useful library for converting ourREADME.md
into an Anki-compatibleREADME.html
You may want to consider changing the version of aqt
in pyproject.toml
depending on when you’re reading this and the Anki version your add-on plans to support.
Here, we can also add our production dependencies. For example, if our add-on depends on yt-dlp
:
poetry add yt-dlp
Later on, we will cover how these production dependencies can be automatically bundled into our final package.
Invoke Tasks
The bulk of our automation will be implemented using invoke
inside of a tasks.py
file. Let’s import some libraries and define some helpful utilities:
import re
import invoke
from invoke import task
from pathlib import Path
from shutil import copytree
from markdown2 import markdown
def one_line_command(string):
return re.sub("\\s+", " ", string).strip()
def run_invoke_cmd(context, cmd) -> invoke.runners.Result:
return context.run(
one_line_command(cmd),
env=None,
hide=False,
warn=False,
pty=False,
echo=True,
)
We can now use run_invoke_cmd
to run any shell process in our tasks.
Testing
Unit testing is a crucial component of developing and maintaining reliable Anki add-ons. During the initial phase of development, we should strive to isolate and decouple our core business logic from the Anki or QT interfacing logic — making it much easier to test.
Our goal should be to minimize manual end-to-end testing for core functionality and instead write automated tests. This not only adds stability and detects early bugs, but also significantly speeds up development.
Place all of your test files within tests/
and add the following task to tasks.py
:
@task
def test(context):
run_invoke_cmd(
context,
"""
PYTEST_ADDOPTS="--color=yes" poetry run pytest --cov=[src] tests/ --cov-branch
""",
)
Running invoke test
will also report code coverage. __init__.py
contains the logic necessary to connect Anki with your add-on, and can’t be run in isolation. In order to prevent the tests from sourcing our main __init__.py
, we need to wrap everything with an if statement:
# init.py
import sys
if "pytest" not in sys.modules:
...
Linting & Formatting
Before we move onto dependency bundling, let’s quickly set up linting and formatting tasks to ensure code quality and consistency. The static type analyzer will also pick up on invalid usage of the aqt
module, it now includes typings. This will drastically help reduce the number of bugs in our codebase!
To configure pyright
, edit the pyproject.toml
file and specify the source directories:
[tool.pyright]
include = ["[src]", "tests"]
exclude = ["**/__pycache__"]
Now we can set up our tasks:
@task
def format_black(context):
command = """
poetry run black *.py [src]/ tests/ --color 2>&1
"""
result = run_invoke_cmd(context, command)
# black always exits with 0, so we handle the output.
if "reformatted" in result.stdout:
print("invoke: black found issues")
result.exited = 1
raise invoke.exceptions.UnexpectedExit(result)
@task
def lint_pyright(context):
run_invoke_cmd(context, "poetry run pyright")
@task()
def lint(context):
format_black(context)
lint_pyright(context)
@task()
def check(context):
lint(context)
test(context)
CI/CD & Pre-Commit
Using Github Actions, we can automatically trigger these linting and testing tasks per commit or pull request to ensure that our code still functions as expected and contains no major defects. Create a new file called .github/workflow/ci-linux-ubuntu-latest.yml
:
name: "Anki Addon"
on:
push:
branches: ["master"]
pull_request:
branches: ["**"]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Upgrade pip
run: |
python -m pip install --upgrade pip
- name: Install poetry
uses: snok/install-poetry@v1
- name: Install all dependencies
run: |
poetry install --no-interaction
- name: Run lint tasks
run: |
poetry run invoke lint
- name: Run tests
run: |
poetry run invoke test
With pre-commit hooks, we can also run these tasks automatically right before committing, adding another “check” layer. In a new file called .pre-commit-config.yaml
, add:
repos:
- repo: local
hooks:
- id: lint_test
name: lint_test
entry: poetry run invoke check
language: system
always_run: true
pass_filenames: false
Then, install the hooks:
pre-commit install
Qt Tip
If your add-on needs a Qt GUI and you plan on implementing it from scratch, allow me to suggest a much less painful method. Using Qt Designer, you can build entire layouts by simply dragging and dropping components, adjusting UI properties with instant feedback, and can even be generated into python code. After you save the layout in a .ui
file with the tool, you can use the following task to compile it into a Python file:
@task
def compile_ui(context):
run_invoke_cmd(
context,
r"""
find [src] -name "*.ui" -print0 | while read -d $'\0' f; do poetry run pyuic5 -x "$f" -o "${f%.ui}.py"; done
""",
)
It also converts any other .ui
files you may have into Python. For more information on how to use this tool with Python, refer to this article.
Dependency Bundling
From the official addon documention:
From Anki 2.1.50, the packaged builds include most built-in Python modules. Earlier versions ship with only the standard modules necessary to run Anki.
If your add-on uses a standard Python module that has not been included or a package from PyPI, then your add-on will need to bundle the module.
Unfortunately, it isn’t simply just a matter of pip install
, but the least we can do is automate the dependency bundling. Note that for C modules like pandas
, it’s much more complicated and beyond the scope of this article. At that point, you’re better off having your users install the dependencies themselves or decouple your software from Anki using something like AnkiConnect.
First, at the very top of your __init__.py
file, make sure to include the following code:
from os.path import dirname, join # new imports
if "pytest" not in sys.modules:
sys.path.append(join(dirname(__file__), "lib")) # new line
This will allow python to load the vendored dependencies from the lib
directory. To bundle the production dependencies onto this dist/lib
directory, include the following task:
@task
def bundle_libs(context):
lib_location = Path("./dist/lib")
if lib_location.exists():
# avoid rebundling
return
lib_location.mkdir()
run_invoke_cmd(
context,
"poetry export --without-hashes --format=requirements.txt | grep -v '!= \"CPython\"' > requirements.txt",
)
run_invoke_cmd(context, "pip install --target dist/lib -r requirements.txt")
run_invoke_cmd(context, "find . -name '*cpython*' -type f -delete")
Essentially, it first retrieves the packages required by the add-on, including their dependencies. It then uses pip to install these packages to the lib
directory, filtering out the ones that rely on C modules. It’s simple, but a powerful and much-needed automation — simplifying bundling for add-ons that rely on several packages.
If your add-on relies on an executable for windows users such as ffmpeg.exe
, the process is a lot easier. A task for bundling would look like this:
@task
def bundle_ffmpeg(_):
# assumes directory ffmpeg/ exists in current directory
# moves ffmpeg.exe into dist/
if not Path("dist/ffmpeg").exists() and Path("ffmpeg").exists():
copytree("ffmpeg", "dist", dirs_exist_ok=True)
Putting it all together
We’ve implemented several tasks that all perform one part of the entire overarching workflow (e.g. bundling, running tests). Now, it’s time to put it all together and create a dev
command to build the add-on and run Anki:
@task
def copy_source(_):
copytree("[src]", "dist", dirs_exist_ok=True)
@task
def package_dev(context):
compile_ui(context) # optional ui code generation
copy_source(context)
bundle_libs(context)
bundle_ffmpeg(context) # optional exe dependency
@task()
def dev(context):
package_dev(context)
anki(context)
We defined copy_source
which just copies all the files within your top-level source directory into a dist
directory. However, dist
needs to be linked to the addons21
directory which Anki reads to load add-ons:
invoke package-dev
ln -s ./dist ~/.local/share/Anki2/addons21/[addon-name]
Perfect, now we can start using the dev
task to update changes within our build as we develop the add-on.
Final packaging
All that’s left now is to define a task to turn the dist/
directory into a .ankiaddon
file that can be uploaded to AnkiWeb. The .ankiaddon
file is actually just a zip file of the directory contents, but it cannot contain __pycache__
anywhere. To clean out dist
, add this task:
@task
def remove_pycache(_):
[p.unlink() for p in Path(".").rglob("*.py[co]")]
[p.rmdir() for p in Path(".").rglob("__pycache__")]
And now we can write the task to compress it into an .ankiaddon
:
@task
def compress(context):
run_invoke_cmd(context, "(cd dist && zip -r $OLDPWD/[addon-name].ankiaddon .)")
In the add-on upload/update, Anki expects a README
, but in HTML and limited in tags. This sounds like a job for automation! We can just convert our markdown README.md
file into a “limit html” one in a task that’s part of our main packaging workflow:
# Credit to https://github.com/luoliyan/chinese-support-redux/blob/master/convert-readme.py
@task
def readme_to_html(_):
"""Covert GitHub mardown to AnkiWeb HTML."""
# permitted tags: img, a, b, i, code, ul, ol, li
translate = [
(r"<h1>([^<]+)</h1>", r""),
(r"<h2>([^<]+)</h2>", r"<b><i>\1</i></b>\n\n"),
(r"<h3>([^<]+)</h3>", r"<b>\1</b>\n\n"),
(r"<strong>([^<]+)</strong>", r"<b>\1</b>"),
(r"<em>([^<]+)</em>", r"<i>\1</i>"),
(r"<kbd>([^<]+)</kbd>", r"<code><b>\1</b></code>"),
(r"</a></p>", r"</a></p>\n"),
(r"<p>", r""),
(r"</p>", r"\n\n"),
(r"</(ol|ul)>(?!</(li|[ou]l)>)", r"</\1>\n"),
]
with open("README.md", encoding="utf-8") as f:
html = "".join(filter(None, markdown(f.read()).split("\n")))
for a, b in translate:
html = re.sub(a, b, html)
with open("README.html", "w", encoding="utf-8") as f:
f.write(html.strip())
All credit for this function goes to Joseph Lorimer, found in his chinese-support-redux
addon.
Finally, we have everything we need now to create the last task:
@task
def package(context):
package_dev(context)
readme_to_html(context)
remove_pycache(context)
compress(context)
Now, every time you create a new functioning release (use git tags!), you can call invoke package
to create the addon and updated html which you can simply upload to AnkiWeb.
Conclusion
Phew, what a journey! It certainly was worth it, because now you have an almost entirely automated workflow and pain-free approach to building anki-addons. The key is to treat it like a normal Python project while also addressing certain caveats such as the __init__.py
pytest fix that we discussed earlier. My aim was to hopefully address many of these caveats and document a functioning example workflow that could serve as a reference to future add-on developers. Best of luck with your projects!