diff --git a/.cspell.dict/cpython.txt b/.cspell.dict/cpython.txt index fbb467c94db..441e8af5b8f 100644 --- a/.cspell.dict/cpython.txt +++ b/.cspell.dict/cpython.txt @@ -48,6 +48,7 @@ posonlyarg posonlyargs prec preinitialized +pythonw PYTHREAD_NAME SA_ONSTACK SOABI @@ -65,6 +66,11 @@ unparse unparser VARKEYWORDS varkwarg +venvlauncher +venvlaunchert +venvw +venvwlauncher +venvwlaunchert wbits weakreflist webpki diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a0947af853b..6dc9b75b138 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,6 +18,11 @@ concurrency: env: CARGO_ARGS: --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls CARGO_ARGS_NO_SSL: --no-default-features --features stdlib,importlib,stdio,encodings,sqlite + # Crates excluded from workspace builds: + # - rustpython_wasm: requires wasm target + # - rustpython-compiler-source: deprecated + # - rustpython-venvlauncher: Windows-only + WORKSPACE_EXCLUDES: --exclude rustpython_wasm --exclude rustpython-compiler-source --exclude rustpython-venvlauncher # Skip additional tests on Windows. They are checked on Linux and MacOS. # test_glob: many failing tests # test_pathlib: panic by surrogate chars @@ -135,13 +140,13 @@ jobs: if: runner.os == 'macOS' - name: run clippy - run: cargo clippy ${{ env.CARGO_ARGS }} --workspace --all-targets --exclude rustpython_wasm --exclude rustpython-compiler-source -- -Dwarnings + run: cargo clippy ${{ env.CARGO_ARGS }} --workspace --all-targets ${{ env.WORKSPACE_EXCLUDES }} -- -Dwarnings - name: run rust tests - run: cargo test --workspace --exclude rustpython_wasm --exclude rustpython-compiler-source --verbose --features threading ${{ env.CARGO_ARGS }} + run: cargo test --workspace ${{ env.WORKSPACE_EXCLUDES }} --verbose --features threading ${{ env.CARGO_ARGS }} if: runner.os != 'macOS' - name: run rust tests - run: cargo test --workspace --exclude rustpython_wasm --exclude rustpython-jit --exclude rustpython-compiler-source --verbose --features threading ${{ env.CARGO_ARGS }} + run: cargo test --workspace ${{ env.WORKSPACE_EXCLUDES }} --exclude rustpython-jit --verbose --features threading ${{ env.CARGO_ARGS }} if: runner.os == 'macOS' - name: check compilation without threading diff --git a/Cargo.lock b/Cargo.lock index 96fc9aa8d29..ffcc1e37c17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3225,6 +3225,13 @@ dependencies = [ "xz2", ] +[[package]] +name = "rustpython-venvlauncher" +version = "0.4.0" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "rustpython-vm" version = "0.4.0" diff --git a/Lib/venv/scripts/nt/venvlauncher.exe b/Lib/venv/scripts/nt/venvlauncher.exe new file mode 100644 index 00000000000..2439c22aa93 Binary files /dev/null and b/Lib/venv/scripts/nt/venvlauncher.exe differ diff --git a/Lib/venv/scripts/nt/venvlaunchert.exe b/Lib/venv/scripts/nt/venvlaunchert.exe new file mode 100644 index 00000000000..99f5f5e9fca Binary files /dev/null and b/Lib/venv/scripts/nt/venvlaunchert.exe differ diff --git a/Lib/venv/scripts/nt/venvwlauncher.exe b/Lib/venv/scripts/nt/venvwlauncher.exe new file mode 100644 index 00000000000..6c43c2e9d93 Binary files /dev/null and b/Lib/venv/scripts/nt/venvwlauncher.exe differ diff --git a/Lib/venv/scripts/nt/venvwlaunchert.exe b/Lib/venv/scripts/nt/venvwlaunchert.exe new file mode 100644 index 00000000000..74f40deb046 Binary files /dev/null and b/Lib/venv/scripts/nt/venvwlaunchert.exe differ diff --git a/crates/venvlauncher/Cargo.toml b/crates/venvlauncher/Cargo.toml new file mode 100644 index 00000000000..d59a3a0269f --- /dev/null +++ b/crates/venvlauncher/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "rustpython-venvlauncher" +description = "Lightweight venv launcher for RustPython" +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +repository.workspace = true +license.workspace = true + +[[bin]] +name = "venvlauncher" +path = "src/main.rs" + +[[bin]] +name = "venvwlauncher" +path = "src/main.rs" + +# Free-threaded variants (RustPython uses Py_GIL_DISABLED=true) +[[bin]] +name = "venvlaunchert" +path = "src/main.rs" + +[[bin]] +name = "venvwlaunchert" +path = "src/main.rs" + +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_System_Threading", + "Win32_System_Environment", + "Win32_Storage_FileSystem", + "Win32_System_Console", + "Win32_Security", +] } + +[lints] +workspace = true diff --git a/crates/venvlauncher/build.rs b/crates/venvlauncher/build.rs new file mode 100644 index 00000000000..404f46a484f --- /dev/null +++ b/crates/venvlauncher/build.rs @@ -0,0 +1,21 @@ +//! Build script for venvlauncher +//! +//! Sets the Windows subsystem to GUI for venvwlauncher variants. +//! Only MSVC toolchain is supported on Windows (same as CPython). + +fn main() { + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + + // Only apply on Windows with MSVC toolchain + if target_os == "windows" && target_env == "msvc" { + let exe_name = std::env::var("CARGO_BIN_NAME").unwrap_or_default(); + + // venvwlauncher and venvwlaunchert should be Windows GUI applications + // (no console window) + if exe_name.contains("venvw") { + println!("cargo:rustc-link-arg=/SUBSYSTEM:WINDOWS"); + println!("cargo:rustc-link-arg=/ENTRY:mainCRTStartup"); + } + } +} diff --git a/crates/venvlauncher/src/main.rs b/crates/venvlauncher/src/main.rs new file mode 100644 index 00000000000..aaf584dfa87 --- /dev/null +++ b/crates/venvlauncher/src/main.rs @@ -0,0 +1,155 @@ +//! RustPython venv launcher +//! +//! A lightweight launcher that reads pyvenv.cfg and delegates execution +//! to the actual Python interpreter. This mimics CPython's venvlauncher.c. +//! Windows only. + +#[cfg(not(windows))] +compile_error!("venvlauncher is only supported on Windows"); + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +fn main() -> ExitCode { + match run() { + Ok(code) => ExitCode::from(code as u8), + Err(e) => { + eprintln!("venvlauncher error: {}", e); + ExitCode::from(1) + } + } +} + +fn run() -> Result> { + // 1. Get own executable path + let exe_path = env::current_exe()?; + let exe_name = exe_path + .file_name() + .ok_or("Failed to get executable name")? + .to_string_lossy(); + + // 2. Determine target executable name based on launcher name + // pythonw.exe / venvwlauncher -> pythonw.exe (GUI, no console) + // python.exe / venvlauncher -> python.exe (console) + let exe_name_lower = exe_name.to_lowercase(); + let target_exe = if exe_name_lower.contains("pythonw") || exe_name_lower.contains("venvw") { + "pythonw.exe" + } else { + "python.exe" + }; + + // 3. Find pyvenv.cfg + // The launcher is in Scripts/ directory, pyvenv.cfg is in parent (venv root) + let scripts_dir = exe_path.parent().ok_or("Failed to get Scripts directory")?; + let venv_dir = scripts_dir.parent().ok_or("Failed to get venv directory")?; + let cfg_path = venv_dir.join("pyvenv.cfg"); + + if !cfg_path.exists() { + return Err(format!("pyvenv.cfg not found: {}", cfg_path.display()).into()); + } + + // 4. Parse home= from pyvenv.cfg + let home = read_home(&cfg_path)?; + + // 5. Locate python executable in home directory + let python_path = PathBuf::from(&home).join(target_exe); + if !python_path.exists() { + return Err(format!("Python not found: {}", python_path.display()).into()); + } + + // 6. Set __PYVENV_LAUNCHER__ environment variable + // This tells Python it was launched from a venv + // SAFETY: We are in a single-threaded context (program entry point) + unsafe { + env::set_var("__PYVENV_LAUNCHER__", &exe_path); + } + + // 7. Launch Python with same arguments + let args: Vec = env::args().skip(1).collect(); + launch_process(&python_path, &args) +} + +/// Parse the `home=` value from pyvenv.cfg +fn read_home(cfg_path: &Path) -> Result> { + let content = fs::read_to_string(cfg_path)?; + + for line in content.lines() { + let line = line.trim(); + // Skip comments and empty lines + if line.is_empty() || line.starts_with('#') { + continue; + } + + // Look for "home = " or "home=" + if let Some(rest) = line.strip_prefix("home") { + let rest = rest.trim_start(); + if let Some(value) = rest.strip_prefix('=') { + return Ok(value.trim().to_string()); + } + } + } + + Err("'home' key not found in pyvenv.cfg".into()) +} + +/// Launch the Python process and wait for it to complete +fn launch_process(exe: &Path, args: &[String]) -> Result> { + use std::process::Command; + + let status = Command::new(exe).args(args).status()?; + + Ok(status.code().unwrap_or(1) as u32) +} + +#[cfg(all(test, windows))] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn test_read_home() { + let temp_dir = std::env::temp_dir(); + let cfg_path = temp_dir.join("test_pyvenv.cfg"); + + let mut file = fs::File::create(&cfg_path).unwrap(); + writeln!(file, "home = C:\\Python313").unwrap(); + writeln!(file, "include-system-site-packages = false").unwrap(); + writeln!(file, "version = 3.13.0").unwrap(); + + let home = read_home(&cfg_path).unwrap(); + assert_eq!(home, "C:\\Python313"); + + fs::remove_file(&cfg_path).unwrap(); + } + + #[test] + fn test_read_home_no_spaces() { + let temp_dir = std::env::temp_dir(); + let cfg_path = temp_dir.join("test_pyvenv2.cfg"); + + let mut file = fs::File::create(&cfg_path).unwrap(); + writeln!(file, "home=C:\\Python313").unwrap(); + + let home = read_home(&cfg_path).unwrap(); + assert_eq!(home, "C:\\Python313"); + + fs::remove_file(&cfg_path).unwrap(); + } + + #[test] + fn test_read_home_with_comments() { + let temp_dir = std::env::temp_dir(); + let cfg_path = temp_dir.join("test_pyvenv3.cfg"); + + let mut file = fs::File::create(&cfg_path).unwrap(); + writeln!(file, "# This is a comment").unwrap(); + writeln!(file, "home = D:\\RustPython").unwrap(); + + let home = read_home(&cfg_path).unwrap(); + assert_eq!(home, "D:\\RustPython"); + + fs::remove_file(&cfg_path).unwrap(); + } +} diff --git a/crates/vm/src/getpath.rs b/crates/vm/src/getpath.rs index 423b9b54136..011d5336873 100644 --- a/crates/vm/src/getpath.rs +++ b/crates/vm/src/getpath.rs @@ -110,19 +110,26 @@ pub fn init_path_config(settings: &Settings) -> Paths { // Step 0: Get executable path let executable = get_executable_path(); - paths.executable = executable + let real_executable = executable .as_ref() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(); - let exe_dir = executable - .as_ref() - .and_then(|p| p.parent().map(PathBuf::from)); - // Step 1: Check for __PYVENV_LAUNCHER__ environment variable - if let Ok(launcher) = env::var("__PYVENV_LAUNCHER__") { - paths.base_executable = launcher; - } + // When launched from a venv launcher, __PYVENV_LAUNCHER__ contains the venv's python.exe path + // In this case: + // - sys.executable should be the launcher path (where user invoked Python) + // - sys._base_executable should be the real Python executable + let exe_dir = if let Ok(launcher) = env::var("__PYVENV_LAUNCHER__") { + paths.executable = launcher.clone(); + paths.base_executable = real_executable; + PathBuf::from(&launcher).parent().map(PathBuf::from) + } else { + paths.executable = real_executable; + executable + .as_ref() + .and_then(|p| p.parent().map(PathBuf::from)) + }; // Step 2: Check for venv (pyvenv.cfg) and get 'home' let (venv_prefix, home_dir) = detect_venv(&exe_dir);