Blog
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 - A make 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 checker
  • pytest & pytest-cov - Full-featured testing tool and plugin to produce coverage reports
  • markdown2 - Useful library for converting our README.md into an Anki-compatible README.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!

Further Reading