Anki/build/configure/src/python.rs
Alexander Bocken b205008a5e
respect env var UV_BINARY with OFFLINE_BUILD being set (#4170)
* respect env var UV_BINARY with OFFLINE_BUILD being set

* cleanup formatting, fix import

* Fix build error (dae)
2025-07-07 15:16:00 +07:00

300 lines
9.4 KiB
Rust

// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use anyhow::Result;
use ninja_gen::action::BuildAction;
use ninja_gen::archives::Platform;
use ninja_gen::build::FilesHandle;
use ninja_gen::copy::CopyFiles;
use ninja_gen::glob;
use ninja_gen::input::BuildInput;
use ninja_gen::inputs;
use ninja_gen::python::python_format;
use ninja_gen::python::PythonEnvironment;
use ninja_gen::python::PythonTypecheck;
use ninja_gen::python::RuffCheck;
use ninja_gen::Build;
/// Normalize version string by removing leading zeros from numeric parts
/// while preserving pre-release markers (b1, rc2, a3, etc.)
fn normalize_version(version: &str) -> String {
version
.split('.')
.map(|part| {
// Check if the part contains only digits
if part.chars().all(|c| c.is_ascii_digit()) {
// Numeric part: remove leading zeros
part.parse::<u32>().unwrap_or(0).to_string()
} else {
// Mixed part (contains both numbers and pre-release markers)
// Split on first non-digit character and normalize the numeric prefix
let chars = part.chars();
let mut numeric_prefix = String::new();
let mut rest = String::new();
let mut found_non_digit = false;
for ch in chars {
if ch.is_ascii_digit() && !found_non_digit {
numeric_prefix.push(ch);
} else {
found_non_digit = true;
rest.push(ch);
}
}
if numeric_prefix.is_empty() {
part.to_string()
} else {
let normalized_prefix = numeric_prefix.parse::<u32>().unwrap_or(0).to_string();
format!("{normalized_prefix}{rest}")
}
}
})
.collect::<Vec<_>>()
.join(".")
}
pub fn setup_venv(build: &mut Build) -> Result<()> {
let extra_binary_exports = &["mypy", "ruff", "pytest", "protoc-gen-mypy"];
build.add_action(
"pyenv",
PythonEnvironment {
venv_folder: "pyenv",
deps: inputs![
"pyproject.toml",
"pylib/pyproject.toml",
"qt/pyproject.toml",
"uv.lock"
],
extra_args: "--all-packages --extra qt --extra audio",
extra_binary_exports,
},
)?;
Ok(())
}
pub struct GenPythonProto {
pub proto_files: BuildInput,
}
impl BuildAction for GenPythonProto {
fn command(&self) -> &str {
"$protoc $
--plugin=protoc-gen-mypy=$protoc-gen-mypy $
--python_out=$builddir/pylib $
--mypy_out=$builddir/pylib $
-Iproto $in"
}
fn files(&mut self, build: &mut impl FilesHandle) {
let proto_inputs = build.expand_inputs(&self.proto_files);
let python_outputs: Vec<_> = proto_inputs
.iter()
.flat_map(|path| {
let path = path
.replace('\\', "/")
.replace("proto/", "pylib/")
.replace(".proto", "_pb2");
[format!("{path}.py"), format!("{path}.pyi")]
})
.collect();
build.add_inputs("in", &self.proto_files);
build.add_inputs("protoc", inputs![":protoc_binary"]);
build.add_inputs("protoc-gen-mypy", inputs![":pyenv:protoc-gen-mypy"]);
build.add_outputs("", python_outputs);
}
fn hide_progress(&self) -> bool {
true
}
}
pub struct BuildWheel {
pub name: &'static str,
pub version: String,
pub platform: Option<Platform>,
pub deps: BuildInput,
}
impl BuildAction for BuildWheel {
fn command(&self) -> &str {
"$uv build --wheel --out-dir=$out_dir --project=$project_dir"
}
fn files(&mut self, build: &mut impl FilesHandle) {
if std::env::var("OFFLINE_BUILD").ok().as_deref() == Some("1") {
let uv_path =
std::env::var("UV_BINARY").expect("UV_BINARY must be set in OFFLINE_BUILD mode");
build.add_inputs("uv", inputs![uv_path]);
} else {
build.add_inputs("uv", inputs![":uv_binary"]);
}
build.add_inputs("", &self.deps);
// Set the project directory based on which package we're building
let project_dir = if self.name == "anki" { "pylib" } else { "qt" };
build.add_variable("project_dir", project_dir);
// Set environment variable for uv to use our pyenv
build.add_variable("pyenv_path", "$builddir/pyenv");
build.add_env_var("UV_PROJECT_ENVIRONMENT", "$pyenv_path");
// Set output directory
build.add_variable("out_dir", "$builddir/wheels/");
// Calculate the wheel filename that uv will generate
let tag = if let Some(platform) = self.platform {
let platform_tag = match platform {
Platform::LinuxX64 => "manylinux_2_36_x86_64",
Platform::LinuxArm => "manylinux_2_36_aarch64",
Platform::MacX64 => "macosx_12_0_x86_64",
Platform::MacArm => "macosx_12_0_arm64",
Platform::WindowsX64 => "win_amd64",
Platform::WindowsArm => "win_arm64",
};
format!("cp39-abi3-{platform_tag}")
} else {
"py3-none-any".into()
};
// Set environment variable for hatch_build.py to use the correct platform tag
build.add_variable("wheel_tag", &tag);
build.add_env_var("ANKI_WHEEL_TAG", "$wheel_tag");
let name = self.name;
let normalized_version = normalize_version(&self.version);
let wheel_path = format!("wheels/{name}-{normalized_version}-{tag}.whl");
build.add_outputs("out", vec![wheel_path]);
}
}
pub fn check_python(build: &mut Build) -> Result<()> {
python_format(build, "tools", inputs![glob!("tools/**/*.py")])?;
build.add_action(
"check:mypy",
PythonTypecheck {
folders: &[
"pylib",
"qt/aqt",
"qt/tools",
"out/pylib/anki",
"out/qt/_aqt",
"python",
"tools",
],
deps: inputs![
glob!["{pylib,ftl,qt}/**/*.{py,pyi}"],
":pylib:anki",
":qt:aqt"
],
},
)?;
let ruff_folders = &["qt/aqt", "ftl", "pylib/tools", "tools", "python"];
let ruff_deps = inputs![
glob!["{pylib,ftl,qt,python,tools}/**/*.py"],
":pylib:anki",
":qt:aqt"
];
build.add_action(
"check:ruff",
RuffCheck {
folders: ruff_folders,
deps: ruff_deps.clone(),
check_only: true,
},
)?;
build.add_action(
"fix:ruff",
RuffCheck {
folders: ruff_folders,
deps: ruff_deps,
check_only: false,
},
)?;
Ok(())
}
struct Sphinx {
deps: BuildInput,
}
impl BuildAction for Sphinx {
fn command(&self) -> &str {
if std::env::var("OFFLINE_BUILD").ok().as_deref() == Some("1") {
"$python python/sphinx/build.py"
} else {
"$uv sync --extra sphinx && $python python/sphinx/build.py"
}
}
fn files(&mut self, build: &mut impl FilesHandle) {
if std::env::var("OFFLINE_BUILD").ok().as_deref() == Some("1") {
let uv_path =
std::env::var("UV_BINARY").expect("UV_BINARY must be set in OFFLINE_BUILD mode");
build.add_inputs("uv", inputs![uv_path]);
} else {
build.add_inputs("uv", inputs![":uv_binary"]);
// Set environment variable to use the existing pyenv
build.add_variable("pyenv_path", "$builddir/pyenv");
build.add_env_var("UV_PROJECT_ENVIRONMENT", "$pyenv_path");
}
build.add_inputs("python", inputs![":pyenv:bin"]);
build.add_inputs("", &self.deps);
build.add_output_stamp("python/sphinx/stamp");
}
fn hide_success(&self) -> bool {
false
}
}
pub(crate) fn setup_sphinx(build: &mut Build) -> Result<()> {
build.add_action(
"python:sphinx:copy_conf",
CopyFiles {
inputs: inputs![glob!("python/sphinx/{conf.py,index.rst}")],
output_folder: "python/sphinx",
},
)?;
build.add_action(
"python:sphinx",
Sphinx {
deps: inputs![
":pylib",
":qt",
":python:sphinx:copy_conf",
"pyproject.toml"
],
},
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_version_basic() {
assert_eq!(normalize_version("1.2.3"), "1.2.3");
assert_eq!(normalize_version("01.02.03"), "1.2.3");
assert_eq!(normalize_version("1.0.0"), "1.0.0");
}
#[test]
fn test_normalize_version_with_prerelease() {
assert_eq!(normalize_version("1.2.3b1"), "1.2.3b1");
assert_eq!(normalize_version("01.02.03b1"), "1.2.3b1");
assert_eq!(normalize_version("1.0.0rc2"), "1.0.0rc2");
assert_eq!(normalize_version("2.1.0a3"), "2.1.0a3");
assert_eq!(normalize_version("1.2.3beta1"), "1.2.3beta1");
assert_eq!(normalize_version("1.2.3alpha1"), "1.2.3alpha1");
}
}