Anki/qt/aqt/package.py
Damien Elmes bb1b289690 Add some helpers to allow add-ons to install packages into the venv
While something we probably don't want to encourage much of, this
may enable some previously-unshared add-ons.

https://forums.ankiweb.net/t/bundling-numpy-in-an-add-on/62669/5

The 'uv add' command is transaction, so if an add-on tries to inject
incompatible dependencies into the environment, the venv will be
left as-is. And each Anki upgrade/downgrade resets the requirements,
so the requested packages shouldn't cause errors down the line.

Sample add-on:

import subprocess
from aqt import mw
from aqt.operations import QueryOp
from aqt.qt import QAction
from aqt.utils import showInfo

def ensure_spacy(col):
    print("trying to import spacy")
    try:
        import spacy
        print("successful import")
        return
    except Exception as e:
        print("error importing:", e)

    print("attempting add")
    try:
        from aqt.package import add_python_requirements as add
    except Exception as e:
        raise Exception(f"package unavailable, can't install: {e}")
    # be explicit about version, or Anki beta users will get
    # a beta wheel that may break
    (success, output) = add(["spacy==3.8.7", "https://github.com/explosion/spacy-models/releases/download/ko_core_news_sm-3.8.0/ko_core_news_sm-3.8.0-py3-none-any.whl"])
    if not success:
        raise Exception(f"adding failed: {output}")

    print("success")

    # alterantively:
    # from aqt.package import venv_binary
    # subprocess.run([venv_binary("spacy"), "download", "ko_core_news_sm"], check=True)
    # print("model added")

    # large packages will freeze for a while on first import on macOS
    import spacy
    print("spacy import successful")

def activate_spacy():
    def on_success(res):
        mw.progress.single_shot(1000, lambda: showInfo("Spacy installed"))

    QueryOp(parent=mw, op=ensure_spacy, success=on_success).with_progress("Installing spacy...").run_in_background()

action = QAction("Activate Spacy", mw)
action.triggered.connect(activate_spacy)
mw.form.menuTools.addAction(action)
2025-07-04 14:23:04 +07:00

168 lines
4.2 KiB
Python

# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
"""Helpers for the packaged version of Anki."""
from __future__ import annotations
import contextlib
import os
import subprocess
import sys
from pathlib import Path
from anki.utils import is_mac, is_win
# ruff: noqa: F401
def first_run_setup() -> None:
"""Code run the first time after install/upgrade.
Currently, we just import our main libraries and invoke
mpv/lame on macOS, which is slow on the first run, and doing
it this way shows progress being made.
"""
if not is_mac:
return
# Import anki_audio first and spawn commands
import anki_audio
audio_pkg_path = Path(anki_audio.__file__).parent
# Start mpv and lame commands concurrently
processes = []
for cmd_name in ["mpv", "lame"]:
cmd_path = audio_pkg_path / cmd_name
proc = subprocess.Popen(
[str(cmd_path), "--version"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
processes.append(proc)
# Continue with other imports while commands run
import concurrent.futures
import bs4
import flask
import flask_cors
import markdown
import PyQt6.QtCore
import PyQt6.QtGui
import PyQt6.QtNetwork
import PyQt6.QtQuick
import PyQt6.QtWebChannel
import PyQt6.QtWebEngineCore
import PyQt6.QtWebEngineWidgets
import PyQt6.QtWidgets
import PyQt6.sip
import requests
import waitress
import anki.collection
from . import _macos_helper
# Wait for both commands to complete
for proc in processes:
proc.wait()
def uv_binary() -> str | None:
"""Return the path to the uv binary."""
return os.environ.get("ANKI_LAUNCHER_UV")
def launcher_root() -> str | None:
"""Return the path to the launcher root directory (AnkiProgramFiles)."""
return os.environ.get("UV_PROJECT")
def venv_binary(cmd: str) -> str | None:
"""Return the path to a binary in the launcher's venv."""
root = launcher_root()
if not root:
return None
root_path = Path(root)
if is_win:
binary_path = root_path / ".venv" / "Scripts" / cmd
else:
binary_path = root_path / ".venv" / "bin" / cmd
return str(binary_path)
def add_python_requirements(reqs: list[str]) -> tuple[bool, str]:
"""Add Python requirements to the launcher venv using uv add.
Returns (success, output)"""
binary = uv_binary()
if not binary:
return (False, "Not in packaged build.")
uv_cmd = [binary, "add"] + reqs
result = subprocess.run(uv_cmd, capture_output=True, text=True, check=False)
if result.returncode == 0:
root = launcher_root()
if root:
sync_marker = Path(root) / ".sync_complete"
sync_marker.touch()
return (True, result.stdout)
else:
return (False, result.stderr)
def launcher_executable() -> str | None:
"""Return the path to the Anki launcher executable."""
return os.getenv("ANKI_LAUNCHER")
def trigger_launcher_run() -> None:
"""Bump the mtime on pyproject.toml in the local data directory to trigger an update on next run."""
try:
root = launcher_root()
if not root:
return
pyproject_path = Path(root) / "pyproject.toml"
if pyproject_path.exists():
# Touch the file to update its mtime
pyproject_path.touch()
except Exception as e:
print(e)
def update_and_restart() -> None:
"""Update and restart Anki using the launcher."""
from aqt import mw
launcher = launcher_executable()
assert launcher
trigger_launcher_run()
with contextlib.suppress(ResourceWarning):
env = os.environ.copy()
creationflags = 0
if sys.platform == "win32":
creationflags = (
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
)
subprocess.Popen(
[launcher],
start_new_session=True,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
creationflags=creationflags,
)
mw.app.quit()