From cd7193150674e636585d81bc5017fc6383539f88 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 20 Jun 2025 01:20:13 +0700 Subject: [PATCH] Launcher tweaks - Handle beta/rc tags in .version when launching Anki - Update pyproject.toml/.python_version if distributed version newer - Support prerelease marker to opt in to betas - Check for updates when using uv sync - Avoid system Python by default, as it can cause breakages (e.g. ARM Python installed on Windows) --- pylib/anki/utils.py | 9 +++++++-- qt/hatch_build.py | 6 +++--- qt/launcher/mac/build.sh | 1 + qt/launcher/pyproject.toml | 14 -------------- qt/launcher/src/main.rs | 39 +++++++++++++++++++------------------- rslib/io/src/lib.rs | 36 +++++++++++++++++++++++++++++++++++ 6 files changed, 67 insertions(+), 38 deletions(-) diff --git a/pylib/anki/utils.py b/pylib/anki/utils.py index 46daa3b97..c61fd0588 100644 --- a/pylib/anki/utils.py +++ b/pylib/anki/utils.py @@ -309,12 +309,17 @@ def int_version() -> int: """Anki's version as an integer in the form YYMMPP, e.g. 230900. (year, month, patch). In 2.1.x releases, this was just the last number.""" + import re + from anki.buildinfo import version + # Strip non-numeric characters (handles beta/rc suffixes like '25.02b1' or 'rc3') + numeric_version = re.sub(r"[^0-9.]", "", version) + try: - [year, month, patch] = version.split(".") + [year, month, patch] = numeric_version.split(".") except ValueError: - [year, month] = version.split(".") + [year, month] = numeric_version.split(".") patch = "0" year_num = int(year) diff --git a/qt/hatch_build.py b/qt/hatch_build.py index 52ca7a0ec..fc716a57f 100644 --- a/qt/hatch_build.py +++ b/qt/hatch_build.py @@ -36,10 +36,10 @@ class CustomBuildHook(BuildHookInterface): def _set_anki_dependency(self, version: str, build_data: Dict[str, Any]) -> None: # Get current dependencies and replace 'anki' with exact version dependencies = build_data.setdefault("dependencies", []) - + # Remove any existing anki dependency dependencies[:] = [dep for dep in dependencies if not dep.startswith("anki")] - + # Handle version detection actual_version = version if version == "standard": @@ -48,7 +48,7 @@ class CustomBuildHook(BuildHookInterface): version_file = project_root / ".version" if version_file.exists(): actual_version = version_file.read_text().strip() - + # Only add exact version for real releases, not editable installs if actual_version != "editable": dependencies.append(f"anki=={actual_version}") diff --git a/qt/launcher/mac/build.sh b/qt/launcher/mac/build.sh index 8a60d488d..eb4483488 100755 --- a/qt/launcher/mac/build.sh +++ b/qt/launcher/mac/build.sh @@ -8,6 +8,7 @@ APP_LAUNCHER="$OUTPUT_DIR/Anki.app" rm -rf "$APP_LAUNCHER" # Build binaries for both architectures +rustup target add aarch64-apple-darwin x86_64-apple-darwin cargo build -p launcher --release --target aarch64-apple-darwin cargo build -p launcher --release --target x86_64-apple-darwin (cd ../../.. && ./ninja launcher:uv_universal) diff --git a/qt/launcher/pyproject.toml b/qt/launcher/pyproject.toml index 6ba027844..2a45626c7 100644 --- a/qt/launcher/pyproject.toml +++ b/qt/launcher/pyproject.toml @@ -5,18 +5,4 @@ description = "UV-based launcher for Anki." requires-python = ">=3.9" dependencies = [ "anki-release", - # so we can use testpypi - "anki", - "aqt", ] - -[tool.uv.sources] -anki-release = { index = "testpypi" } -anki = { index = "testpypi" } -aqt = { index = "testpypi" } - -[[tool.uv.index]] -name = "testpypi" -url = "https://test.pypi.org/simple/" -publish-url = "https://test.pypi.org/legacy/" -explicit = true diff --git a/qt/launcher/src/main.rs b/qt/launcher/src/main.rs index 77268ce3a..e40253eff 100644 --- a/qt/launcher/src/main.rs +++ b/qt/launcher/src/main.rs @@ -6,9 +6,9 @@ use std::io::stdin; use std::process::Command; -use anki_io::copy_file; +use anki_io::copy_if_newer; use anki_io::create_dir_all; -use anki_io::metadata; +use anki_io::modified_time; use anki_io::remove_file; use anki_io::write_file; use anki_process::CommandExt; @@ -51,6 +51,7 @@ fn run() -> Result<()> { .join("AnkiProgramFiles"); let sync_complete_marker = uv_install_root.join(".sync_complete"); + let prerelease_marker = uv_install_root.join("prerelease"); let (exe_dir, resources_dir) = get_exe_and_resources_dirs()?; let dist_pyproject_path = resources_dir.join("pyproject.toml"); let user_pyproject_path = uv_install_root.join("pyproject.toml"); @@ -59,14 +60,15 @@ fn run() -> Result<()> { let uv_lock_path = uv_install_root.join("uv.lock"); let uv_path: std::path::PathBuf = exe_dir.join(get_uv_binary_name()); + // Create install directory and copy project files in + create_dir_all(&uv_install_root)?; + copy_if_newer(&dist_pyproject_path, &user_pyproject_path)?; + copy_if_newer(&dist_python_version_path, &user_python_version_path)?; + let pyproject_has_changed = !user_pyproject_path.exists() || !sync_complete_marker.exists() || { - let pyproject_toml_time = metadata(&user_pyproject_path)? - .modified() - .context("Failed to get pyproject.toml modified time")?; - let sync_complete_time = metadata(&sync_complete_marker)? - .modified() - .context("Failed to get sync marker modified time")?; + let pyproject_toml_time = modified_time(&user_pyproject_path)?; + let sync_complete_time = modified_time(&sync_complete_marker)?; Ok::(pyproject_toml_time > sync_complete_time) } .unwrap_or(true); @@ -81,22 +83,21 @@ fn run() -> Result<()> { // we'll need to launch uv; reinvoke ourselves in a terminal so the user can see handle_terminal_launch()?; - // Create install directory and copy project files in - create_dir_all(&uv_install_root)?; - if !user_pyproject_path.exists() { - copy_file(&dist_pyproject_path, &user_pyproject_path)?; - copy_file(&dist_python_version_path, &user_python_version_path)?; - } - // Remove sync marker before attempting sync let _ = remove_file(&sync_complete_marker); // Sync the venv - if let Err(e) = Command::new(&uv_path) + let mut command = Command::new(&uv_path); + command .current_dir(&uv_install_root) - .args(["sync", "--refresh"]) - .ensure_success() - { + .args(["sync", "--upgrade", "--managed-python"]); + + // Set UV_PRERELEASE=allow if prerelease file exists + if prerelease_marker.exists() { + command.env("UV_PRERELEASE", "allow"); + } + + if let Err(e) = command.ensure_success() { // If sync fails due to things like a missing wheel on pypi, // we need to remove the lockfile or uv will cache the bad result. let _ = remove_file(&uv_lock_path); diff --git a/rslib/io/src/lib.rs b/rslib/io/src/lib.rs index c1d4c0205..cb44467e6 100644 --- a/rslib/io/src/lib.rs +++ b/rslib/io/src/lib.rs @@ -152,6 +152,34 @@ pub fn copy_file(src: impl AsRef, dst: impl AsRef) -> Result { }) } +/// Copy a file from src to dst if dst doesn't exist or if src is newer than +/// dst. Preserves the modification time from the source file. +pub fn copy_if_newer(src: impl AsRef, dst: impl AsRef) -> Result { + let src = src.as_ref(); + let dst = dst.as_ref(); + + let should_copy = if !dst.exists() { + true + } else { + let src_time = modified_time(src)?; + let dst_time = modified_time(dst)?; + src_time > dst_time + }; + + if should_copy { + copy_file(src, dst)?; + + // Preserve the modification time from the source file + let src_mtime = modified_time(src)?; + let times = FileTimes::new().set_modified(src_mtime); + set_file_times(dst, times)?; + + Ok(true) + } else { + Ok(false) + } +} + /// Like [read_file], but skips the section that is potentially locked by /// SQLite. pub fn read_locked_db_file(path: impl AsRef) -> Result> { @@ -188,6 +216,14 @@ pub fn metadata(path: impl AsRef) -> Result { }) } +/// Get the modification time of a file. +pub fn modified_time(path: impl AsRef) -> Result { + metadata(&path)?.modified().context(FileIoSnafu { + path: path.as_ref(), + op: FileOp::Metadata, + }) +} + pub fn new_tempfile() -> Result { NamedTempFile::new().context(FileIoSnafu { path: std::env::temp_dir(),