mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
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:
parent
e81a7e8b1a
commit
bb1b289690
6 changed files with 215 additions and 113 deletions
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue