diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 4e0b82dd5..c707d1b2a 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -1309,7 +1309,7 @@ title="{}" {}>{}""".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="{}" {}>{}""".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="{}" {}>{}""".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) diff --git a/qt/aqt/package.py b/qt/aqt/package.py index d6236c4cd..f85a17335 100644 --- a/qt/aqt/package.py +++ b/qt/aqt/package.py @@ -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() diff --git a/qt/aqt/update.py b/qt/aqt/update.py index 61fec8e6b..e5794eead 100644 --- a/qt/aqt/update.py +++ b/qt/aqt/update.py @@ -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) diff --git a/qt/launcher/addon/__init__.py b/qt/launcher/addon/__init__.py index fb0168d14..71f2fa116 100644 --- a/qt/launcher/addon/__init__.py +++ b/qt/launcher/addon/__init__.py @@ -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() diff --git a/qt/launcher/pyproject.toml b/qt/launcher/pyproject.toml index 2a45626c7..cc521b432 100644 --- a/qt/launcher/pyproject.toml +++ b/qt/launcher/pyproject.toml @@ -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 = [ diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index 679b84834..629fbd881 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -128,7 +128,7 @@ fn run() -> Result<()> { if !pyproject_has_changed { // If venv is already up to date, launch Anki normally let args: Vec = 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 { Ok(true) } -fn build_python_command(uv_install_root: &std::path::Path, args: &[String]) -> Result { +fn build_python_command(state: &State, args: &[String]) -> Result { 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) }