Skip to content

Commit 40acd55

Browse files
authored
venvlauncher (RustPython#6527)
1 parent 2063c1e commit 40acd55

11 files changed

Lines changed: 251 additions & 11 deletions

File tree

.cspell.dict/cpython.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ posonlyarg
4848
posonlyargs
4949
prec
5050
preinitialized
51+
pythonw
5152
PYTHREAD_NAME
5253
SA_ONSTACK
5354
SOABI
@@ -65,6 +66,11 @@ unparse
6566
unparser
6667
VARKEYWORDS
6768
varkwarg
69+
venvlauncher
70+
venvlaunchert
71+
venvw
72+
venvwlauncher
73+
venvwlaunchert
6874
wbits
6975
weakreflist
7076
webpki

.github/workflows/ci.yaml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ concurrency:
1818
env:
1919
CARGO_ARGS: --no-default-features --features stdlib,importlib,stdio,encodings,sqlite,ssl-rustls
2020
CARGO_ARGS_NO_SSL: --no-default-features --features stdlib,importlib,stdio,encodings,sqlite
21+
# Crates excluded from workspace builds:
22+
# - rustpython_wasm: requires wasm target
23+
# - rustpython-compiler-source: deprecated
24+
# - rustpython-venvlauncher: Windows-only
25+
WORKSPACE_EXCLUDES: --exclude rustpython_wasm --exclude rustpython-compiler-source --exclude rustpython-venvlauncher
2126
# Skip additional tests on Windows. They are checked on Linux and MacOS.
2227
# test_glob: many failing tests
2328
# test_pathlib: panic by surrogate chars
@@ -135,13 +140,13 @@ jobs:
135140
if: runner.os == 'macOS'
136141

137142
- name: run clippy
138-
run: cargo clippy ${{ env.CARGO_ARGS }} --workspace --all-targets --exclude rustpython_wasm --exclude rustpython-compiler-source -- -Dwarnings
143+
run: cargo clippy ${{ env.CARGO_ARGS }} --workspace --all-targets ${{ env.WORKSPACE_EXCLUDES }} -- -Dwarnings
139144

140145
- name: run rust tests
141-
run: cargo test --workspace --exclude rustpython_wasm --exclude rustpython-compiler-source --verbose --features threading ${{ env.CARGO_ARGS }}
146+
run: cargo test --workspace ${{ env.WORKSPACE_EXCLUDES }} --verbose --features threading ${{ env.CARGO_ARGS }}
142147
if: runner.os != 'macOS'
143148
- name: run rust tests
144-
run: cargo test --workspace --exclude rustpython_wasm --exclude rustpython-jit --exclude rustpython-compiler-source --verbose --features threading ${{ env.CARGO_ARGS }}
149+
run: cargo test --workspace ${{ env.WORKSPACE_EXCLUDES }} --exclude rustpython-jit --verbose --features threading ${{ env.CARGO_ARGS }}
145150
if: runner.os == 'macOS'
146151

147152
- name: check compilation without threading

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
263 KB
Binary file not shown.
263 KB
Binary file not shown.
263 KB
Binary file not shown.
263 KB
Binary file not shown.

crates/venvlauncher/Cargo.toml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
[package]
2+
name = "rustpython-venvlauncher"
3+
description = "Lightweight venv launcher for RustPython"
4+
version.workspace = true
5+
authors.workspace = true
6+
edition.workspace = true
7+
rust-version.workspace = true
8+
repository.workspace = true
9+
license.workspace = true
10+
11+
[[bin]]
12+
name = "venvlauncher"
13+
path = "src/main.rs"
14+
15+
[[bin]]
16+
name = "venvwlauncher"
17+
path = "src/main.rs"
18+
19+
# Free-threaded variants (RustPython uses Py_GIL_DISABLED=true)
20+
[[bin]]
21+
name = "venvlaunchert"
22+
path = "src/main.rs"
23+
24+
[[bin]]
25+
name = "venvwlaunchert"
26+
path = "src/main.rs"
27+
28+
[target.'cfg(windows)'.dependencies]
29+
windows-sys = { workspace = true, features = [
30+
"Win32_Foundation",
31+
"Win32_System_Threading",
32+
"Win32_System_Environment",
33+
"Win32_Storage_FileSystem",
34+
"Win32_System_Console",
35+
"Win32_Security",
36+
] }
37+
38+
[lints]
39+
workspace = true

crates/venvlauncher/build.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//! Build script for venvlauncher
2+
//!
3+
//! Sets the Windows subsystem to GUI for venvwlauncher variants.
4+
//! Only MSVC toolchain is supported on Windows (same as CPython).
5+
6+
fn main() {
7+
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap();
8+
let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default();
9+
10+
// Only apply on Windows with MSVC toolchain
11+
if target_os == "windows" && target_env == "msvc" {
12+
let exe_name = std::env::var("CARGO_BIN_NAME").unwrap_or_default();
13+
14+
// venvwlauncher and venvwlaunchert should be Windows GUI applications
15+
// (no console window)
16+
if exe_name.contains("venvw") {
17+
println!("cargo:rustc-link-arg=/SUBSYSTEM:WINDOWS");
18+
println!("cargo:rustc-link-arg=/ENTRY:mainCRTStartup");
19+
}
20+
}
21+
}

crates/venvlauncher/src/main.rs

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
//! RustPython venv launcher
2+
//!
3+
//! A lightweight launcher that reads pyvenv.cfg and delegates execution
4+
//! to the actual Python interpreter. This mimics CPython's venvlauncher.c.
5+
//! Windows only.
6+
7+
#[cfg(not(windows))]
8+
compile_error!("venvlauncher is only supported on Windows");
9+
10+
use std::env;
11+
use std::fs;
12+
use std::path::{Path, PathBuf};
13+
use std::process::ExitCode;
14+
15+
fn main() -> ExitCode {
16+
match run() {
17+
Ok(code) => ExitCode::from(code as u8),
18+
Err(e) => {
19+
eprintln!("venvlauncher error: {}", e);
20+
ExitCode::from(1)
21+
}
22+
}
23+
}
24+
25+
fn run() -> Result<u32, Box<dyn std::error::Error>> {
26+
// 1. Get own executable path
27+
let exe_path = env::current_exe()?;
28+
let exe_name = exe_path
29+
.file_name()
30+
.ok_or("Failed to get executable name")?
31+
.to_string_lossy();
32+
33+
// 2. Determine target executable name based on launcher name
34+
// pythonw.exe / venvwlauncher -> pythonw.exe (GUI, no console)
35+
// python.exe / venvlauncher -> python.exe (console)
36+
let exe_name_lower = exe_name.to_lowercase();
37+
let target_exe = if exe_name_lower.contains("pythonw") || exe_name_lower.contains("venvw") {
38+
"pythonw.exe"
39+
} else {
40+
"python.exe"
41+
};
42+
43+
// 3. Find pyvenv.cfg
44+
// The launcher is in Scripts/ directory, pyvenv.cfg is in parent (venv root)
45+
let scripts_dir = exe_path.parent().ok_or("Failed to get Scripts directory")?;
46+
let venv_dir = scripts_dir.parent().ok_or("Failed to get venv directory")?;
47+
let cfg_path = venv_dir.join("pyvenv.cfg");
48+
49+
if !cfg_path.exists() {
50+
return Err(format!("pyvenv.cfg not found: {}", cfg_path.display()).into());
51+
}
52+
53+
// 4. Parse home= from pyvenv.cfg
54+
let home = read_home(&cfg_path)?;
55+
56+
// 5. Locate python executable in home directory
57+
let python_path = PathBuf::from(&home).join(target_exe);
58+
if !python_path.exists() {
59+
return Err(format!("Python not found: {}", python_path.display()).into());
60+
}
61+
62+
// 6. Set __PYVENV_LAUNCHER__ environment variable
63+
// This tells Python it was launched from a venv
64+
// SAFETY: We are in a single-threaded context (program entry point)
65+
unsafe {
66+
env::set_var("__PYVENV_LAUNCHER__", &exe_path);
67+
}
68+
69+
// 7. Launch Python with same arguments
70+
let args: Vec<String> = env::args().skip(1).collect();
71+
launch_process(&python_path, &args)
72+
}
73+
74+
/// Parse the `home=` value from pyvenv.cfg
75+
fn read_home(cfg_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
76+
let content = fs::read_to_string(cfg_path)?;
77+
78+
for line in content.lines() {
79+
let line = line.trim();
80+
// Skip comments and empty lines
81+
if line.is_empty() || line.starts_with('#') {
82+
continue;
83+
}
84+
85+
// Look for "home = <path>" or "home=<path>"
86+
if let Some(rest) = line.strip_prefix("home") {
87+
let rest = rest.trim_start();
88+
if let Some(value) = rest.strip_prefix('=') {
89+
return Ok(value.trim().to_string());
90+
}
91+
}
92+
}
93+
94+
Err("'home' key not found in pyvenv.cfg".into())
95+
}
96+
97+
/// Launch the Python process and wait for it to complete
98+
fn launch_process(exe: &Path, args: &[String]) -> Result<u32, Box<dyn std::error::Error>> {
99+
use std::process::Command;
100+
101+
let status = Command::new(exe).args(args).status()?;
102+
103+
Ok(status.code().unwrap_or(1) as u32)
104+
}
105+
106+
#[cfg(all(test, windows))]
107+
mod tests {
108+
use super::*;
109+
use std::io::Write;
110+
111+
#[test]
112+
fn test_read_home() {
113+
let temp_dir = std::env::temp_dir();
114+
let cfg_path = temp_dir.join("test_pyvenv.cfg");
115+
116+
let mut file = fs::File::create(&cfg_path).unwrap();
117+
writeln!(file, "home = C:\\Python313").unwrap();
118+
writeln!(file, "include-system-site-packages = false").unwrap();
119+
writeln!(file, "version = 3.13.0").unwrap();
120+
121+
let home = read_home(&cfg_path).unwrap();
122+
assert_eq!(home, "C:\\Python313");
123+
124+
fs::remove_file(&cfg_path).unwrap();
125+
}
126+
127+
#[test]
128+
fn test_read_home_no_spaces() {
129+
let temp_dir = std::env::temp_dir();
130+
let cfg_path = temp_dir.join("test_pyvenv2.cfg");
131+
132+
let mut file = fs::File::create(&cfg_path).unwrap();
133+
writeln!(file, "home=C:\\Python313").unwrap();
134+
135+
let home = read_home(&cfg_path).unwrap();
136+
assert_eq!(home, "C:\\Python313");
137+
138+
fs::remove_file(&cfg_path).unwrap();
139+
}
140+
141+
#[test]
142+
fn test_read_home_with_comments() {
143+
let temp_dir = std::env::temp_dir();
144+
let cfg_path = temp_dir.join("test_pyvenv3.cfg");
145+
146+
let mut file = fs::File::create(&cfg_path).unwrap();
147+
writeln!(file, "# This is a comment").unwrap();
148+
writeln!(file, "home = D:\\RustPython").unwrap();
149+
150+
let home = read_home(&cfg_path).unwrap();
151+
assert_eq!(home, "D:\\RustPython");
152+
153+
fs::remove_file(&cfg_path).unwrap();
154+
}
155+
}

0 commit comments

Comments
 (0)