use std::io::{Cursor, Write}; use std::path::Path; use std::process::Command; use std::{env, io}; use anyhow::Result; use assert_cmd::prelude::OutputAssertExt; use assert_fs::prelude::PathChild; use fs_err::File; use thiserror::Error; use which::which; use zip::write::FileOptions; use zip::ZipWriter; use uv_fs::Simplified; const LAUNCHER_MAGIC_NUMBER: [u8; 4] = [b'U', b'V', b'U', b'V']; #[cfg(all(windows, target_arch = "x86"))] const LAUNCHER_I686_GUI: &[u8] = include_bytes!("../trampolines/uv-trampoline-i686-gui.exe"); #[cfg(all(windows, target_arch = "x86"))] const LAUNCHER_I686_CONSOLE: &[u8] = include_bytes!("../trampolines/uv-trampoline-i686-console.exe"); #[cfg(all(windows, target_arch = "x86_64"))] const LAUNCHER_X86_64_GUI: &[u8] = include_bytes!("../trampolines/uv-trampoline-x86_64-gui.exe"); #[cfg(all(windows, target_arch = "x86_64"))] const LAUNCHER_X86_64_CONSOLE: &[u8] = include_bytes!("../trampolines/uv-trampoline-x86_64-console.exe"); #[cfg(all(windows, target_arch = "aarch64"))] const LAUNCHER_AARCH64_GUI: &[u8] = include_bytes!("../trampolines/uv-trampoline-aarch64-gui.exe"); #[cfg(all(windows, target_arch = "aarch64"))] const LAUNCHER_AARCH64_CONSOLE: &[u8] = include_bytes!("../trampolines/uv-trampoline-aarch64-console.exe"); /// Note: The caller is responsible for adding the path of the wheel we're installing. #[derive(Error, Debug)] pub enum Error { #[error(transparent)] Io(#[from] io::Error), #[error( "Unable to create Windows launcher for: {0} (only x86_64, x86, and arm64 are supported)" )] UnsupportedWindowsArch(&'static str), #[error("Unable to create Windows launcher on non-Windows platform")] NotWindows, } /// Wrapper script template function /// /// fn get_script_launcher(shebang: &str, is_gui: bool) -> String { if is_gui { format!( r##"{shebang} # -*- coding: utf-8 -*- import re import sys def make_gui() -> None: from tkinter import Tk, ttk root = Tk() root.title("uv Test App") frm = ttk.Frame(root, padding=10) frm.grid() ttk.Label(frm, text="Hello from uv-trampoline-gui.exe").grid(column=0, row=0) root.mainloop() if __name__ == "__main__": sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) sys.exit(make_gui()) "## ) } else { format!( r##"{shebang} # -*- coding: utf-8 -*- import re import sys def main_console() -> None: print("Hello from uv-trampoline-console.exe", file=sys.stdout) print("Hello from uv-trampoline-console.exe", file=sys.stderr) if __name__ == "__main__": sys.argv[0] = re.sub(r"(-script\.pyw|\.exe)?$", "", sys.argv[0]) sys.exit(main_console()) "## ) } } /// Format the shebang for a given Python executable. /// /// Like pip, if a shebang is non-simple (too long or contains spaces), we use `/bin/sh` as the /// executable. /// /// See: fn format_shebang(executable: impl AsRef) -> String { // Convert the executable to a simplified path. let executable = executable.as_ref().simplified_display().to_string(); format!("#!{executable}") } /// A Windows script is a minimal .exe launcher binary with the python entrypoint script appended as /// stored zip file. /// /// #[allow(unused_variables)] fn windows_script_launcher( launcher_python_script: &str, is_gui: bool, python_executable: impl AsRef, ) -> Result, Error> { // This method should only be called on Windows, but we avoid `#[cfg(windows)]` to retain // compilation on all platforms. if cfg!(not(windows)) { return Err(Error::NotWindows); } let launcher_bin: &[u8] = match env::consts::ARCH { #[cfg(all(windows, target_arch = "x86"))] "x86" => { if is_gui { LAUNCHER_I686_GUI } else { LAUNCHER_I686_CONSOLE } } #[cfg(all(windows, target_arch = "x86_64"))] "x86_64" => { if is_gui { LAUNCHER_X86_64_GUI } else { LAUNCHER_X86_64_CONSOLE } } #[cfg(all(windows, target_arch = "aarch64"))] "aarch64" => { if is_gui { LAUNCHER_AARCH64_GUI } else { LAUNCHER_AARCH64_CONSOLE } } #[cfg(windows)] arch => { return Err(Error::UnsupportedWindowsArch(arch)); } #[cfg(not(windows))] arch => &[], }; let mut payload: Vec = Vec::new(); { // We're using the zip writer, but with stored compression // https://github.com/njsmith/posy/blob/04927e657ca97a5e35bb2252d168125de9a3a025/src/trampolines/mod.rs#L75-L82 // https://github.com/pypa/distlib/blob/8ed03aab48add854f377ce392efffb79bb4d6091/PC/launcher.c#L259-L271 let stored = FileOptions::default().compression_method(zip::CompressionMethod::Stored); let mut archive = ZipWriter::new(Cursor::new(&mut payload)); let error_msg = "Writing to Vec should never fail"; archive.start_file("__main__.py", stored).expect(error_msg); archive .write_all(launcher_python_script.as_bytes()) .expect(error_msg); archive.finish().expect(error_msg); } let python = python_executable.as_ref(); let python_path = python.simplified_display().to_string(); let mut launcher: Vec = Vec::with_capacity(launcher_bin.len() + payload.len()); launcher.extend_from_slice(launcher_bin); launcher.extend_from_slice(&payload); launcher.extend_from_slice(python_path.as_bytes()); launcher.extend_from_slice( &u32::try_from(python_path.as_bytes().len()) .expect("File Path to be smaller than 4GB") .to_le_bytes(), ); launcher.extend_from_slice(&LAUNCHER_MAGIC_NUMBER); Ok(launcher) } #[test] fn generate_console_launcher() -> Result<()> { // Create Temp Dirs let temp_dir = assert_fs::TempDir::new()?; let console_bin_path = temp_dir.child("launcher.console.exe"); // Locate an arbitrary python installation from PATH let python_executable_path = which("python")?; // Generate Launcher Script let launcher_console_script = get_script_launcher(&format_shebang(&python_executable_path), false); // Generate Launcher Payload let console_launcher = windows_script_launcher(&launcher_console_script, false, &python_executable_path)?; // Create Launcher File::create(console_bin_path.path())?.write_all(console_launcher.as_ref())?; println!( "Wrote Console Launcher in {}", console_bin_path.path().simplified_display() ); // Test Console Launcher #[cfg(windows)] Command::new(console_bin_path.path()) .assert() .success() .stdout("Hello from uv-trampoline-console.exe\r\n") .stderr("Hello from uv-trampoline-console.exe\r\n"); Ok(()) } #[test] #[ignore] fn generate_gui_launcher() -> Result<()> { // Create Temp Dirs let temp_dir = assert_fs::TempDir::new()?; let gui_bin_path = temp_dir.child("launcher.gui.exe"); // Locate an arbitrary pythonw installation from PATH let pythonw_executable_path = which("pythonw")?; // Generate Launcher Script let launcher_gui_script = get_script_launcher(&format_shebang(&pythonw_executable_path), true); // Generate Launcher Payload let gui_launcher = windows_script_launcher(&launcher_gui_script, true, &pythonw_executable_path)?; // Create Launcher File::create(gui_bin_path.path())?.write_all(gui_launcher.as_ref())?; println!( "Wrote GUI Launcher in {}", gui_bin_path.path().simplified_display() ); // Test GUI Launcher // NOTICE: This will spawn a GUI and will wait until you close the window. #[cfg(windows)] Command::new(gui_bin_path.path()).assert().success(); Ok(()) }