Skip to main content

rustpython_vm/
getpath.rs

1//! Path configuration for RustPython (ref: Modules/getpath.py)
2//!
3//! This module implements Python path calculation logic following getpath.py.
4//! It uses landmark-based search to locate prefix, exec_prefix, and stdlib directories.
5//!
6//! The main entry point is `init_path_config()` which computes Paths from Settings.
7
8use crate::vm::{Paths, Settings};
9use std::env;
10use std::path::{Path, PathBuf};
11
12// Platform-specific landmarks (ref: getpath.py PLATFORM CONSTANTS)
13
14#[cfg(not(windows))]
15mod platform {
16    use crate::version;
17
18    pub(super) const BUILDDIR_TXT: &str = "pybuilddir.txt";
19    pub(super) const BUILD_LANDMARK: &str = "Modules/Setup.local";
20    pub(super) const VENV_LANDMARK: &str = "pyvenv.cfg";
21    pub(super) const BUILDSTDLIB_LANDMARK: &str = "Lib/os.py";
22
23    pub(super) fn stdlib_subdir() -> String {
24        format!("lib/python{}.{}", version::MAJOR, version::MINOR)
25    }
26
27    pub(super) fn stdlib_landmarks() -> [String; 2] {
28        let subdir = stdlib_subdir();
29        [format!("{subdir}/os.py"), format!("{subdir}/os.pyc")]
30    }
31
32    pub(super) fn platstdlib_landmark() -> String {
33        format!(
34            "lib/python{}.{}/lib-dynload",
35            version::MAJOR,
36            version::MINOR
37        )
38    }
39
40    pub(super) fn zip_landmark() -> String {
41        format!("lib/python{}{}.zip", version::MAJOR, version::MINOR)
42    }
43}
44
45#[cfg(windows)]
46mod platform {
47    use crate::version;
48
49    pub(super) const BUILDDIR_TXT: &str = "pybuilddir.txt";
50    pub(super) const BUILD_LANDMARK: &str = "Modules\\Setup.local";
51    pub(super) const VENV_LANDMARK: &str = "pyvenv.cfg";
52    pub(super) const BUILDSTDLIB_LANDMARK: &str = "Lib\\os.py";
53    pub(super) const STDLIB_SUBDIR: &str = "Lib";
54
55    pub(super) fn stdlib_landmarks() -> [String; 2] {
56        ["Lib\\os.py".into(), "Lib\\os.pyc".into()]
57    }
58
59    pub(super) fn platstdlib_landmark() -> String {
60        "DLLs".into()
61    }
62
63    pub(super) fn zip_landmark() -> String {
64        format!("python{}{}.zip", version::MAJOR, version::MINOR)
65    }
66}
67
68// Helper functions (ref: getpath.py HELPER FUNCTIONS)
69
70/// Search upward from a directory for landmark files/directories
71/// Returns the directory where a landmark was found
72fn search_up<P, F>(start: P, landmarks: &[&str], test: F) -> Option<PathBuf>
73where
74    P: AsRef<Path>,
75    F: Fn(&Path) -> bool,
76{
77    let mut current = start.as_ref().to_path_buf();
78    loop {
79        for landmark in landmarks {
80            let path = current.join(landmark);
81            if test(&path) {
82                return Some(current);
83            }
84        }
85        if !current.pop() {
86            return None;
87        }
88    }
89}
90
91/// Search upward for a file landmark
92fn search_up_file<P: AsRef<Path>>(start: P, landmarks: &[&str]) -> Option<PathBuf> {
93    search_up(start, landmarks, |p| p.is_file())
94}
95
96/// Search upward for a directory landmark
97#[cfg(not(windows))]
98fn search_up_dir<P: AsRef<Path>>(start: P, landmarks: &[&str]) -> Option<PathBuf> {
99    search_up(start, landmarks, |p| p.is_dir())
100}
101
102// Path computation functions
103
104/// Compute path configuration from Settings
105///
106/// This function should be called before interpreter initialization.
107/// It returns a Paths struct with all computed path values.
108pub fn init_path_config(settings: &Settings) -> Paths {
109    let mut paths = Paths::default();
110
111    // Step 0: Get executable path
112    let executable = get_executable_path();
113    let real_executable = executable
114        .as_ref()
115        .map(|p| p.to_string_lossy().into_owned())
116        .unwrap_or_default();
117
118    // Step 1: Check for PYTHONEXECUTABLE / __PYVENV_LAUNCHER__
119    // When set, these are used as sys.executable and when searching for venvs.
120    // The argv0 path is kept as the base executable for prefix calculation.
121    let exe_dir = if let Some(override_exe) = env_executable_override() {
122        paths.executable.clone_from(&override_exe);
123        paths.base_executable = real_executable;
124        PathBuf::from(&override_exe).parent().map(PathBuf::from)
125    } else {
126        paths.executable = real_executable;
127        executable
128            .as_ref()
129            .and_then(|p| p.parent().map(PathBuf::from))
130    };
131
132    // Step 2: Check for venv (pyvenv.cfg) and get 'home'
133    let (venv_prefix, home_dir) = detect_venv(exe_dir.as_ref());
134    let search_dir = home_dir.clone().or(exe_dir);
135
136    // Step 3: Check for build directory
137    let build_prefix = detect_build_directory(search_dir.as_ref());
138
139    // Step 4: Calculate prefix via landmark search
140    // When in venv, search_dir is home_dir, so this gives us the base Python's prefix
141    let calculated_prefix = calculate_prefix(search_dir.as_ref(), build_prefix.as_ref());
142
143    // Step 5: Set prefix and base_prefix
144    if venv_prefix.is_some() {
145        // In venv: prefix = venv directory, base_prefix = original Python's prefix
146        paths.prefix = venv_prefix.as_ref().map_or_else(
147            || calculated_prefix.clone(),
148            |p| p.to_string_lossy().into_owned(),
149        );
150        paths.base_prefix = calculated_prefix;
151    } else {
152        // Not in venv: prefix == base_prefix
153        paths.prefix.clone_from(&calculated_prefix);
154        paths.base_prefix = calculated_prefix;
155    }
156
157    // Step 6: Calculate exec_prefix
158    paths.exec_prefix = if venv_prefix.is_some() {
159        // In venv: exec_prefix = prefix (venv directory)
160        paths.prefix.clone()
161    } else {
162        calculate_exec_prefix(search_dir.as_ref(), paths.prefix.as_ref())
163    };
164    paths.base_exec_prefix.clone_from(&paths.base_prefix);
165
166    // Step 7: Calculate base_executable (if not already set by an env override)
167    if paths.base_executable.is_empty() {
168        paths.base_executable = calculate_base_executable(executable.as_ref(), home_dir.as_ref());
169    }
170
171    // Step 8: Build module_search_paths
172    paths.module_search_paths =
173        build_module_search_paths(settings, &paths.prefix, &paths.exec_prefix);
174
175    // Step 9: Calculate stdlib_dir
176    paths.stdlib_dir = calculate_stdlib_dir(&paths.prefix);
177
178    paths
179}
180
181/// Get default prefix value used when landmark search fails.
182///
183/// A compile-time `RUSTPYTHON_PREFIX` always wins. Otherwise POSIX uses the
184/// conventional install prefix, while Windows has no meaningful compile-time
185/// prefix and falls back to the executable's directory (ref: getpath.py).
186///
187/// A bare drive root must never be returned on Windows: pip walks up from
188/// `<prefix>/Lib/site-packages` and would otherwise probe the drive root for
189/// writability, which fails for standard users (see issue #8246).
190fn default_prefix(exe_dir: Option<&PathBuf>) -> String {
191    if let Some(prefix) = std::option_env!("RUSTPYTHON_PREFIX") {
192        return prefix.to_owned();
193    }
194
195    if cfg!(windows) {
196        if let Some(dir) = exe_dir {
197            return dir.to_string_lossy().into_owned();
198        }
199        // Executable directory is unknown; use a valid absolute root as a last
200        // resort rather than a drive-relative bare "C:".
201        "C:\\".to_owned()
202    } else {
203        "/usr/local".to_owned()
204    }
205}
206
207/// Detect virtual environment by looking for pyvenv.cfg
208/// Returns (venv_prefix, home_dir from pyvenv.cfg)
209fn detect_venv(exe_dir: Option<&PathBuf>) -> (Option<PathBuf>, Option<PathBuf>) {
210    // Try exe_dir/../pyvenv.cfg first (standard venv layout: venv/bin/python)
211    if let Some(dir) = exe_dir
212        && let Some(venv_dir) = dir.parent()
213    {
214        let cfg = venv_dir.join(platform::VENV_LANDMARK);
215        if cfg.exists()
216            && let Some(home) = parse_pyvenv_home(&cfg)
217        {
218            return (Some(venv_dir.to_path_buf()), Some(PathBuf::from(home)));
219        }
220    }
221
222    // Try exe_dir/pyvenv.cfg (alternative layout)
223    if let Some(dir) = exe_dir {
224        let cfg = dir.join(platform::VENV_LANDMARK);
225        if cfg.exists()
226            && let Some(home) = parse_pyvenv_home(&cfg)
227        {
228            return (Some(dir.clone()), Some(PathBuf::from(home)));
229        }
230    }
231
232    (None, None)
233}
234
235/// Detect if running from a build directory
236fn detect_build_directory(exe_dir: Option<&PathBuf>) -> Option<PathBuf> {
237    let dir = exe_dir?;
238
239    // Check for pybuilddir.txt (indicates build directory)
240    if dir.join(platform::BUILDDIR_TXT).exists() {
241        return Some(dir.clone());
242    }
243
244    // Check for Modules/Setup.local (build landmark)
245    if dir.join(platform::BUILD_LANDMARK).exists() {
246        return Some(dir.clone());
247    }
248
249    // Search up for Lib/os.py (build stdlib landmark)
250    search_up_file(dir, &[platform::BUILDSTDLIB_LANDMARK])
251}
252
253/// Calculate prefix by searching for landmarks
254fn calculate_prefix(exe_dir: Option<&PathBuf>, build_prefix: Option<&PathBuf>) -> String {
255    // 1. If build directory detected, use it
256    if let Some(bp) = build_prefix {
257        return bp.to_string_lossy().into_owned();
258    }
259
260    if let Some(dir) = exe_dir {
261        // 2. Search for ZIP landmark
262        let zip = platform::zip_landmark();
263        if let Some(prefix) = search_up_file(dir, &[&zip]) {
264            return prefix.to_string_lossy().into_owned();
265        }
266
267        // 3. Search for stdlib landmarks (os.py)
268        let landmarks = platform::stdlib_landmarks();
269        let refs: Vec<&str> = landmarks.iter().map(|s| s.as_str()).collect();
270        if let Some(prefix) = search_up_file(dir, &refs) {
271            return prefix.to_string_lossy().into_owned();
272        }
273    }
274
275    // 4. Fallback to default
276    default_prefix(exe_dir)
277}
278
279/// Calculate exec_prefix
280fn calculate_exec_prefix(exe_dir: Option<&PathBuf>, prefix: &str) -> String {
281    #[cfg(windows)]
282    {
283        // Windows: exec_prefix == prefix
284        let _ = exe_dir; // silence unused warning
285        prefix.to_owned()
286    }
287
288    #[cfg(not(windows))]
289    {
290        // POSIX: search for lib-dynload directory
291        if let Some(dir) = exe_dir {
292            let landmark = platform::platstdlib_landmark();
293            if let Some(exec_prefix) = search_up_dir(dir, &[&landmark]) {
294                return exec_prefix.to_string_lossy().into_owned();
295            }
296        }
297        // Fallback: same as prefix
298        prefix.to_owned()
299    }
300}
301
302/// Calculate base_executable
303fn calculate_base_executable(executable: Option<&PathBuf>, home_dir: Option<&PathBuf>) -> String {
304    // If in venv and we have home, construct base_executable from home
305    if let (Some(exe), Some(home)) = (executable, home_dir)
306        && let Some(exe_name) = exe.file_name()
307    {
308        let base = home.join(exe_name);
309        return base.to_string_lossy().into_owned();
310    }
311
312    // Otherwise, base_executable == executable
313    executable
314        .map(|p| p.to_string_lossy().into_owned())
315        .unwrap_or_default()
316}
317
318/// Calculate stdlib_dir (sys._stdlib_dir)
319/// Returns None if the stdlib directory doesn't exist
320fn calculate_stdlib_dir(prefix: &str) -> Option<String> {
321    let stdlib_dir = Path::new(prefix).join(cfg_select! {
322        windows => platform::STDLIB_SUBDIR,
323        _ => platform::stdlib_subdir(),
324    });
325
326    if stdlib_dir.is_dir() {
327        Some(stdlib_dir.to_string_lossy().into_owned())
328    } else {
329        None
330    }
331}
332
333/// Build the complete module_search_paths (sys.path)
334fn build_module_search_paths(settings: &Settings, prefix: &str, exec_prefix: &str) -> Vec<String> {
335    let mut paths = Vec::new();
336
337    // 1. PYTHONPATH/RUSTPYTHONPATH from settings
338    paths.extend(settings.path_list.iter().cloned());
339
340    // 2. ZIP file path
341    let zip_path = PathBuf::from(prefix).join(platform::zip_landmark());
342    paths.push(zip_path.to_string_lossy().into_owned());
343
344    // 3. stdlib and platstdlib directories
345    #[cfg(not(windows))]
346    {
347        // POSIX: stdlib first, then lib-dynload
348        let stdlib_dir = PathBuf::from(prefix).join(platform::stdlib_subdir());
349        paths.push(stdlib_dir.to_string_lossy().into_owned());
350
351        let platstdlib = PathBuf::from(exec_prefix).join(platform::platstdlib_landmark());
352        paths.push(platstdlib.to_string_lossy().into_owned());
353    }
354
355    #[cfg(windows)]
356    {
357        // Windows: DLLs first, then Lib
358        let platstdlib = PathBuf::from(exec_prefix).join(platform::platstdlib_landmark());
359        paths.push(platstdlib.to_string_lossy().into_owned());
360
361        let stdlib_dir = PathBuf::from(prefix).join(platform::STDLIB_SUBDIR);
362        paths.push(stdlib_dir.to_string_lossy().into_owned());
363    }
364
365    paths
366}
367
368/// `PYTHONEXECUTABLE` takes precedence over `__PYVENV_LAUNCHER__`.
369/// An empty value is ignored.
370fn env_executable_override() -> Option<String> {
371    for name in ["PYTHONEXECUTABLE", "__PYVENV_LAUNCHER__"] {
372        if let Ok(value) = crate::host_env::os::var(name)
373            && !value.is_empty()
374        {
375            return Some(value);
376        }
377    }
378    None
379}
380
381/// Get the current executable path
382fn get_executable_path() -> Option<PathBuf> {
383    #[cfg(not(target_arch = "wasm32"))]
384    {
385        let exec_arg = env::args_os().next()?;
386        crate::host_env::fs::which(exec_arg)
387    }
388    #[cfg(target_arch = "wasm32")]
389    {
390        let exec_arg = env::args().next()?;
391        Some(PathBuf::from(exec_arg))
392    }
393}
394
395/// Parse pyvenv.cfg and extract the 'home' key value
396fn parse_pyvenv_home(pyvenv_cfg: &Path) -> Option<String> {
397    #[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))]
398    let content = crate::host_env::fs::read_to_string(pyvenv_cfg).ok()?;
399    #[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
400    let content = std::fs::read_to_string(pyvenv_cfg).ok()?;
401
402    for line in content.lines() {
403        if let Some((key, value)) = line.split_once('=')
404            && key.trim().to_lowercase() == "home"
405        {
406            return Some(value.trim().to_string());
407        }
408    }
409
410    None
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn init_path_config_basic() {
419        let settings = Settings::default();
420        let paths = init_path_config(&settings);
421        // Just verify it doesn't panic and returns valid paths
422        assert!(!paths.prefix.is_empty());
423    }
424
425    #[test]
426    fn search_up() {
427        // Test with a path that doesn't have any landmarks
428        let result = search_up_file(
429            crate::host_env::os::temp_dir(),
430            &["nonexistent_landmark_xyz"],
431        );
432        assert!(result.is_none());
433    }
434
435    #[test]
436    fn default_prefix_basic() {
437        let prefix = default_prefix(None);
438        assert!(!prefix.is_empty());
439    }
440}