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)
This commit is contained in:
Damien Elmes 2025-06-20 01:20:13 +07:00
parent 4abc0eb8b8
commit cd71931506
6 changed files with 67 additions and 38 deletions

View file

@ -309,12 +309,17 @@ def int_version() -> int:
"""Anki's version as an integer in the form YYMMPP, e.g. 230900. """Anki's version as an integer in the form YYMMPP, e.g. 230900.
(year, month, patch). (year, month, patch).
In 2.1.x releases, this was just the last number.""" In 2.1.x releases, this was just the last number."""
import re
from anki.buildinfo import version 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: try:
[year, month, patch] = version.split(".") [year, month, patch] = numeric_version.split(".")
except ValueError: except ValueError:
[year, month] = version.split(".") [year, month] = numeric_version.split(".")
patch = "0" patch = "0"
year_num = int(year) year_num = int(year)

View file

@ -36,10 +36,10 @@ class CustomBuildHook(BuildHookInterface):
def _set_anki_dependency(self, version: str, build_data: Dict[str, Any]) -> None: def _set_anki_dependency(self, version: str, build_data: Dict[str, Any]) -> None:
# Get current dependencies and replace 'anki' with exact version # Get current dependencies and replace 'anki' with exact version
dependencies = build_data.setdefault("dependencies", []) dependencies = build_data.setdefault("dependencies", [])
# Remove any existing anki dependency # Remove any existing anki dependency
dependencies[:] = [dep for dep in dependencies if not dep.startswith("anki")] dependencies[:] = [dep for dep in dependencies if not dep.startswith("anki")]
# Handle version detection # Handle version detection
actual_version = version actual_version = version
if version == "standard": if version == "standard":
@ -48,7 +48,7 @@ class CustomBuildHook(BuildHookInterface):
version_file = project_root / ".version" version_file = project_root / ".version"
if version_file.exists(): if version_file.exists():
actual_version = version_file.read_text().strip() actual_version = version_file.read_text().strip()
# Only add exact version for real releases, not editable installs # Only add exact version for real releases, not editable installs
if actual_version != "editable": if actual_version != "editable":
dependencies.append(f"anki=={actual_version}") dependencies.append(f"anki=={actual_version}")

View file

@ -8,6 +8,7 @@ APP_LAUNCHER="$OUTPUT_DIR/Anki.app"
rm -rf "$APP_LAUNCHER" rm -rf "$APP_LAUNCHER"
# Build binaries for both architectures # 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 aarch64-apple-darwin
cargo build -p launcher --release --target x86_64-apple-darwin cargo build -p launcher --release --target x86_64-apple-darwin
(cd ../../.. && ./ninja launcher:uv_universal) (cd ../../.. && ./ninja launcher:uv_universal)

View file

@ -5,18 +5,4 @@ description = "UV-based launcher for Anki."
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"anki-release", "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

View file

@ -6,9 +6,9 @@
use std::io::stdin; use std::io::stdin;
use std::process::Command; use std::process::Command;
use anki_io::copy_file; use anki_io::copy_if_newer;
use anki_io::create_dir_all; use anki_io::create_dir_all;
use anki_io::metadata; use anki_io::modified_time;
use anki_io::remove_file; use anki_io::remove_file;
use anki_io::write_file; use anki_io::write_file;
use anki_process::CommandExt; use anki_process::CommandExt;
@ -51,6 +51,7 @@ fn run() -> Result<()> {
.join("AnkiProgramFiles"); .join("AnkiProgramFiles");
let sync_complete_marker = uv_install_root.join(".sync_complete"); 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 (exe_dir, resources_dir) = get_exe_and_resources_dirs()?;
let dist_pyproject_path = resources_dir.join("pyproject.toml"); let dist_pyproject_path = resources_dir.join("pyproject.toml");
let user_pyproject_path = uv_install_root.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_lock_path = uv_install_root.join("uv.lock");
let uv_path: std::path::PathBuf = exe_dir.join(get_uv_binary_name()); 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 = let pyproject_has_changed =
!user_pyproject_path.exists() || !sync_complete_marker.exists() || { !user_pyproject_path.exists() || !sync_complete_marker.exists() || {
let pyproject_toml_time = metadata(&user_pyproject_path)? let pyproject_toml_time = modified_time(&user_pyproject_path)?;
.modified() let sync_complete_time = modified_time(&sync_complete_marker)?;
.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")?;
Ok::<bool, anyhow::Error>(pyproject_toml_time > sync_complete_time) Ok::<bool, anyhow::Error>(pyproject_toml_time > sync_complete_time)
} }
.unwrap_or(true); .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 // we'll need to launch uv; reinvoke ourselves in a terminal so the user can see
handle_terminal_launch()?; 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 // Remove sync marker before attempting sync
let _ = remove_file(&sync_complete_marker); let _ = remove_file(&sync_complete_marker);
// Sync the venv // Sync the venv
if let Err(e) = Command::new(&uv_path) let mut command = Command::new(&uv_path);
command
.current_dir(&uv_install_root) .current_dir(&uv_install_root)
.args(["sync", "--refresh"]) .args(["sync", "--upgrade", "--managed-python"]);
.ensure_success()
{ // 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, // 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. // we need to remove the lockfile or uv will cache the bad result.
let _ = remove_file(&uv_lock_path); let _ = remove_file(&uv_lock_path);

View file

@ -152,6 +152,34 @@ pub fn copy_file(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> Result<u64> {
}) })
} }
/// 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<Path>, dst: impl AsRef<Path>) -> Result<bool> {
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 /// Like [read_file], but skips the section that is potentially locked by
/// SQLite. /// SQLite.
pub fn read_locked_db_file(path: impl AsRef<Path>) -> Result<Vec<u8>> { pub fn read_locked_db_file(path: impl AsRef<Path>) -> Result<Vec<u8>> {
@ -188,6 +216,14 @@ pub fn metadata(path: impl AsRef<Path>) -> Result<std::fs::Metadata> {
}) })
} }
/// Get the modification time of a file.
pub fn modified_time(path: impl AsRef<Path>) -> Result<std::time::SystemTime> {
metadata(&path)?.modified().context(FileIoSnafu {
path: path.as_ref(),
op: FileOp::Metadata,
})
}
pub fn new_tempfile() -> Result<NamedTempFile> { pub fn new_tempfile() -> Result<NamedTempFile> {
NamedTempFile::new().context(FileIoSnafu { NamedTempFile::new().context(FileIoSnafu {
path: std::env::temp_dir(), path: std::env::temp_dir(),