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)
This commit is contained in:
Damien Elmes 2025-07-04 14:23:04 +07:00
parent e81a7e8b1a
commit bb1b289690
6 changed files with 215 additions and 113 deletions

View file

@ -1309,7 +1309,7 @@ title="{}" {}>{}</button>""".format(
if not askUser(tr.qt_misc_open_anki_launcher()):
return
from aqt.update import update_and_restart
from aqt.package import update_and_restart
update_and_restart()
@ -1394,7 +1394,7 @@ title="{}" {}>{}</button>""".format(
##########################################################################
def setupMenus(self) -> None:
from aqt.update import have_launcher
from aqt.package import launcher_executable
m = self.form
@ -1426,7 +1426,7 @@ title="{}" {}>{}</button>""".format(
qconnect(m.actionEmptyCards.triggered, self.onEmptyCards)
qconnect(m.actionNoteTypes.triggered, self.onNoteTypes)
qconnect(m.action_upgrade_downgrade.triggered, self.on_upgrade_downgrade)
if not have_launcher():
if not launcher_executable():
m.action_upgrade_downgrade.setVisible(False)
qconnect(m.actionPreferences.triggered, self.onPrefs)

View file

@ -5,10 +5,13 @@
from __future__ import annotations
import contextlib
import os
import subprocess
import sys
from pathlib import Path
from anki.utils import is_mac
from anki.utils import is_mac, is_win
# ruff: noqa: F401
@ -65,3 +68,101 @@ def first_run_setup() -> None:
# 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()

View file

@ -3,16 +3,17 @@
from __future__ import annotations
import contextlib
import os
import subprocess
from pathlib import Path
import aqt
from anki.buildinfo import buildhash
from anki.collection import CheckForUpdateResponse, Collection
from anki.utils import dev_mode, int_time, int_version, is_mac, is_win, plat_desc
from anki.utils import dev_mode, int_time, int_version, plat_desc
from aqt.operations import QueryOp
from aqt.package import (
launcher_executable as _launcher_executable,
)
from aqt.package import (
update_and_restart as _update_and_restart,
)
from aqt.qt import *
from aqt.utils import openLink, show_warning, showText, tr
@ -84,67 +85,7 @@ def prompt_to_update(mw: aqt.AnkiQt, ver: str) -> None:
# ignore this update
mw.pm.meta["suppressUpdate"] = ver
elif ret == QMessageBox.StandardButton.Yes:
if have_launcher():
update_and_restart()
if _launcher_executable():
_update_and_restart()
else:
openLink(aqt.appWebsiteDownloadSection)
def _anki_launcher_path() -> str | None:
return os.getenv("ANKI_LAUNCHER")
def have_launcher() -> bool:
return _anki_launcher_path() is not None
def update_and_restart() -> None:
from aqt import mw
launcher = _anki_launcher_path()
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()
def _trigger_launcher_run() -> None:
"""Bump the mtime on pyproject.toml in the local data directory to trigger an update on next run."""
try:
# Get the local data directory equivalent to Rust's dirs::data_local_dir()
if is_win:
from .winpaths import get_local_appdata
data_dir = Path(get_local_appdata())
elif is_mac:
data_dir = Path.home() / "Library" / "Application Support"
else: # Linux
data_dir = Path(
os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")
)
pyproject_path = data_dir / "AnkiProgramFiles" / "pyproject.toml"
if pyproject_path.exists():
# Touch the file to update its mtime
pyproject_path.touch()
except Exception as e:
print(e)

View file

@ -8,29 +8,88 @@ import os
import subprocess
import sys
from pathlib import Path
from typing import Any
import aqt.sound
from anki.utils import pointVersion
from aqt import mw
from aqt.qt import QAction
from aqt.utils import askUser, is_mac, is_win, showInfo
def _anki_launcher_path() -> str | None:
def launcher_executable() -> str | None:
"""Return the path to the Anki launcher executable."""
return os.getenv("ANKI_LAUNCHER")
def have_launcher() -> bool:
return _anki_launcher_path() is not None
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 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:
from aqt import mw
launcher = _anki_launcher_path()
"""Update and restart Anki using the launcher."""
launcher = launcher_executable()
assert launcher
_trigger_launcher_run()
trigger_launcher_run()
with contextlib.suppress(ResourceWarning):
env = os.environ.copy()
@ -52,30 +111,6 @@ def update_and_restart() -> None:
mw.app.quit()
def _trigger_launcher_run() -> None:
"""Bump the mtime on pyproject.toml in the local data directory to trigger an update on next run."""
try:
# Get the local data directory equivalent to Rust's dirs::data_local_dir()
if is_win:
from aqt.winpaths import get_local_appdata
data_dir = Path(get_local_appdata())
elif is_mac:
data_dir = Path.home() / "Library" / "Application Support"
else: # Linux
data_dir = Path(
os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share")
)
pyproject_path = data_dir / "AnkiProgramFiles" / "pyproject.toml"
if pyproject_path.exists():
# Touch the file to update its mtime
pyproject_path.touch()
except Exception as e:
print(e)
def confirm_then_upgrade():
if not askUser("Change to a different Anki version?"):
return
@ -119,7 +154,7 @@ def _packagedCmd(cmd: list[str]) -> tuple[Any, dict[str, str]]:
def setup():
if pointVersion() >= 250600:
return
if not have_launcher():
if not launcher_executable():
return
# Add action to tools menu
@ -129,7 +164,21 @@ def setup():
# Monkey-patch audio tools to use anki-audio
if is_win or is_mac:
import aqt
import aqt.sound
aqt.sound._packagedCmd = _packagedCmd
# Inject launcher functions into launcher module
import aqt.package
aqt.package.launcher_executable = launcher_executable
aqt.package.update_and_restart = update_and_restart
aqt.package.trigger_launcher_run = trigger_launcher_run
aqt.package.uv_binary = uv_binary
aqt.package.launcher_root = launcher_root
aqt.package.venv_binary = venv_binary
aqt.package.add_python_requirements = add_python_requirements
setup()

View file

@ -1,6 +1,6 @@
[project]
name = "anki-launcher"
version = "0.1.0"
version = "1.0.0"
description = "UV-based launcher for Anki."
requires-python = ">=3.9"
dependencies = [

View file

@ -128,7 +128,7 @@ fn run() -> Result<()> {
if !pyproject_has_changed {
// If venv is already up to date, launch Anki normally
let args: Vec<String> = std::env::args().skip(1).collect();
let cmd = build_python_command(&state.uv_install_root, &args)?;
let cmd = build_python_command(&state, &args)?;
launch_anki_normally(cmd)?;
return Ok(());
}
@ -150,7 +150,7 @@ fn run() -> Result<()> {
#[cfg(target_os = "macos")]
{
let cmd = build_python_command(&state.uv_install_root, &[])?;
let cmd = build_python_command(&state, &[])?;
platform::mac::prepare_for_launch_after_update(cmd, &uv_install_root)?;
}
@ -669,24 +669,35 @@ fn handle_uninstall(state: &State) -> Result<bool> {
Ok(true)
}
fn build_python_command(uv_install_root: &std::path::Path, args: &[String]) -> Result<Command> {
fn build_python_command(state: &State, args: &[String]) -> Result<Command> {
let python_exe = if cfg!(target_os = "windows") {
let show_console = std::env::var("ANKI_CONSOLE").is_ok();
if show_console {
uv_install_root.join(".venv/Scripts/python.exe")
state.uv_install_root.join(".venv/Scripts/python.exe")
} else {
uv_install_root.join(".venv/Scripts/pythonw.exe")
state.uv_install_root.join(".venv/Scripts/pythonw.exe")
}
} else {
uv_install_root.join(".venv/bin/python")
state.uv_install_root.join(".venv/bin/python")
};
let mut cmd = Command::new(python_exe);
let mut cmd = Command::new(&python_exe);
cmd.args(["-c", "import aqt, sys; sys.argv[0] = 'Anki'; aqt.run()"]);
cmd.args(args);
// tell the Python code it was invoked by the launcher, and updating is
// available
cmd.env("ANKI_LAUNCHER", std::env::current_exe()?.utf8()?.as_str());
// Set UV and Python paths for the Python code
let (exe_dir, _) = get_exe_and_resources_dirs()?;
let uv_path = exe_dir.join(get_uv_binary_name());
cmd.env("ANKI_LAUNCHER_UV", uv_path.utf8()?.as_str());
cmd.env("UV_PROJECT", state.uv_install_root.utf8()?.as_str());
// Set UV_PRERELEASE=allow if beta mode is enabled
if state.prerelease_marker.exists() {
cmd.env("UV_PRERELEASE", "allow");
}
Ok(cmd)
}