origen/
python.rs

1// Responsible for managing Python execution
2pub use crate::_generated::python::MIN_PYTHON_VERSION;
3use crate::_generated::python::PYTHONS;
4use crate::built_info;
5use origen::core::status::DependencySrc;
6use origen::{Result, STATUS};
7use origen_metal::new_cmd;
8use origen_metal::utils::file::search_backwards_for_first;
9use semver::Version;
10use std::env;
11use std::path::PathBuf;
12use std::process::{Command, ExitStatus, Stdio};
13
14#[macro_export]
15macro_rules! strs_to_cli_arr {
16    ($name:expr, $strs:expr) => {{
17        format!(
18            "{}=[{}]",
19            $name,
20            $strs
21                .map(|t| format!("r'{}'", t))
22                .collect::<Vec<String>>()
23                .join(", ")
24        )
25    }};
26}
27
28lazy_static! {
29    pub static ref PYTHON_CONFIG: Config = Config::default();
30}
31
32macro_rules! pyproject_str {
33    () => {
34        "pyproject.toml"
35    };
36}
37macro_rules! user_env_str {
38    () => {
39        "ORIGEN_PYPROJECT"
40    };
41}
42
43const PYPROJECT: &'static str = pyproject_str!();
44const USER_ENV: &'static str = user_env_str!();
45
46lazy_static! {
47    pub static ref NO_ORIGEN_BOOT_MODULE_ERROR: &'static str =
48        "ModuleNotFoundError: No module named 'origen.boot'";
49}
50
51pub struct Config {
52    pub available: bool,
53    pub command: String,
54}
55
56pub fn resolve_pyproject() -> Result<DependencySrc> {
57    // TODO allow a .origen offset when looking for pyprojects?
58    // let offset = ".origen";
59    if let Some(app) = STATUS.app.as_ref() {
60        let path = app.root.join(PYPROJECT);
61        log_trace!("Found app pyproject: {}", path.display());
62        return Ok(DependencySrc::App(path));
63    }
64
65    if let Some(p) = search_backwards_for_first(env::current_dir()?, |p| {
66        let f = p.join(PYPROJECT);
67        log_trace!("Searching for workspace project from {}", p.display());
68        if f.exists() {
69            log_trace!("Found workspace pyproject: {}", f.display());
70            Ok(Some(f))
71        } else {
72            Ok(None)
73        }
74    })? {
75        return Ok(DependencySrc::Workspace(p.to_path_buf()));
76    }
77
78    if let Some(p) = env::var_os(USER_ENV) {
79        log_trace!(
80            "Attempting to find user-given pyproject: {}",
81            p.to_string_lossy()
82        );
83        let mut f = PathBuf::from(p);
84        if f.exists() {
85            f = f.join(PYPROJECT);
86            if f.exists() {
87                log_trace!("Found user-given pyproject: {}", f.display());
88                return Ok(DependencySrc::UserGlobal(f));
89            } else {
90                bail!(
91                    concat!(
92                        "Could not locate ",
93                        pyproject_str!(),
94                        " from ",
95                        user_env_str!(),
96                        " {}"
97                    ),
98                    f.display()
99                );
100            }
101        } else {
102            bail!(
103                concat!(user_env_str!(), " '{}' does not exists!"),
104                f.display()
105            );
106        }
107    }
108
109    // Try the python package installation directory
110    let path = std::env::current_exe()?;
111    log_trace!(
112        "Searching CLI installation directories for pyproject: {}",
113        path.display()
114    );
115    if let Some(p) = search_backwards_for_first(path, |p| {
116        let f = p.join(PYPROJECT);
117        log_trace!("Searching for workspace project from {}", p.display());
118        if f.exists() {
119            log_trace!("Found workspace pyproject: {}", f.display());
120            Ok(Some(f))
121        } else {
122            Ok(None)
123        }
124    })? {
125        return Ok(DependencySrc::Global(p.to_path_buf()));
126    }
127
128    log_trace!("No pyproject found. Skipping UV invocations...");
129    Ok(DependencySrc::NoneFound)
130}
131
132impl Config {
133    pub fn base_cmd(&self) -> Result<Command> {
134        let dep_src = STATUS.dependency_src();
135        if let Some(path) = dep_src.as_ref().and_then(DependencySrc::src_file) {
136            crate::commands::env::guard_uv_manifest(path)?;
137        }
138        let mut c = if matches!(dep_src.as_ref(), Some(DependencySrc::NoneFound) | None) {
139            new_cmd!(&self.command)
140        } else {
141            new_cmd!("uv")
142        };
143
144        if let Some(dep_src) = dep_src.as_ref() {
145            match dep_src {
146                DependencySrc::App(path)
147                | DependencySrc::Workspace(path)
148                | DependencySrc::UserGlobal(path)
149                | DependencySrc::Global(path) => {
150                    c.arg("--project");
151                    c.arg(path.parent().unwrap_or(path));
152                }
153                DependencySrc::NoneFound => {}
154            }
155        } else {
156            log_error!(
157                "Dependency source has not been set - defaulting to global Python installation"
158            );
159        }
160        Ok(c)
161    }
162
163    pub fn run_cmd(&self, code: &str) -> Result<Command> {
164        let mut c = self.base_cmd()?;
165        if let Some(d) = STATUS.dependency_src().as_ref() {
166            if d.src_available() {
167                c.arg("run");
168                c.arg("--no-sync");
169                c.arg("--no-editable");
170                c.arg(&self.command);
171            }
172        }
173        c.arg("-c");
174        c.arg(code);
175        if let Some(d) = STATUS.dependency_src().as_ref() {
176            c.arg(format!("invocation={}", d));
177            if let Some(path) = d.src_file() {
178                c.arg(format!("pyproject_src={}", path.display()));
179            }
180        }
181        Ok(c)
182    }
183
184    pub fn uv_command(&self) -> Result<Command> {
185        let mut c = Command::new("uv");
186        if let Some(d) = STATUS.dependency_src().as_ref() {
187            if let Some(path) = d.src_file() {
188                crate::commands::env::guard_uv_manifest(path)?;
189                c.arg("--project");
190                c.arg(path.parent().unwrap_or(path));
191            }
192        }
193        Ok(c)
194    }
195}
196
197impl Default for Config {
198    fn default() -> Config {
199        match resolve_pyproject() {
200            Ok(deps) => STATUS.set_dependency_src(Some(deps)),
201            Err(e) => log_error!("Errors encountered resolving pyproject: {}", e),
202        }
203        for cmd in PYTHONS.iter() {
204            log_trace!("Searching for installed python at '{}'", cmd);
205            match get_version(cmd) {
206                Some(version) => {
207                    if version >= Version::parse(MIN_PYTHON_VERSION).unwrap() {
208                        log_trace!("Found python version '{}'", cmd);
209                        return Config {
210                            available: true,
211                            command: cmd.to_string(),
212                        };
213                    }
214                }
215                None => {}
216            }
217        }
218        Config {
219            available: false,
220            command: String::from("python"),
221        }
222    }
223}
224
225/// Get the Python version from the given command
226fn get_version(command: &str) -> Option<Version> {
227    match new_cmd!(command).arg("--version").output() {
228        Ok(output) => return extract_version(std::str::from_utf8(&output.stdout).unwrap()),
229        Err(_e) => return None,
230    }
231}
232
233/// Returns the version of the Python that was discovered for this workspace.
234pub fn python_version() -> Option<Version> {
235    get_version(&PYTHON_CONFIG.command)
236}
237
238/// Returns the installed UV version.
239pub fn uv_version() -> Option<Version> {
240    match Command::new("uv").arg("--version").output() {
241        Ok(output) => {
242            let text = std::str::from_utf8(&output.stdout).unwrap();
243            log_trace!("{}", text);
244            extract_version(text)
245        }
246        Err(e) => {
247            log_debug!("{}", e);
248            None
249        }
250    }
251}
252
253fn extract_version(text: &str) -> Option<Version> {
254    let re = regex::Regex::new(r".*(\d+\.\d+\.\d+)([^\s\)]+)?").unwrap();
255
256    match re.captures(text) {
257        Some(x) => {
258            let c = {
259                let v = x.get(1).unwrap().as_str();
260                if let Some(p) = x.get(2) {
261                    let mut p = p.as_str();
262                    if p.starts_with("-") {
263                        format!("{}{}", v, p)
264                    } else {
265                        if p.starts_with(".") || p.starts_with("+") {
266                            p = &p[1..];
267                        }
268                        format!("{}-{}", v, p)
269                    }
270                } else {
271                    v.to_string()
272                }
273            };
274            match Version::parse(&c) {
275                Ok(v) => {
276                    return Some(v);
277                }
278                Err(e) => {
279                    panic!("Unable to parse version {}. Received Error:\n {}", c, e);
280                }
281            }
282        }
283        None => {
284            return None;
285        }
286    };
287}
288
289/// Execute the given Python code
290pub fn run(code: &str) -> Result<ExitStatus> {
291    let mut cmd = PYTHON_CONFIG.run_cmd(code)?;
292    // current_exe returns the Python process once it gets underway, so pass in the CLI
293    // location for Origen to use (used to resolve Origen config files)
294    if let Ok(p) = std::env::current_exe() {
295        cmd.arg(&format!("origen_cli={}", p.display()));
296    };
297    cmd.arg(&format!("origen_cli_version={}", built_info::PKG_VERSION));
298
299    add_origen_env(&mut cmd);
300
301    log_trace!("Running Python command: '{:?}'", cmd);
302
303    Ok(cmd.status()?)
304}
305
306/// Macro to create a Python command
307#[macro_export]
308macro_rules! python_cmd {
309    ($code:expr) => {{
310        let mut cmd = PYTHON_CONFIG.run_cmd($code)?;
311        if let Ok(p) = std::env::current_exe() {
312            cmd.arg(&format!("origen_cli={}", p.display()));
313        };
314        cmd.arg(&format!("origen_cli_version={}", built_info::PKG_VERSION));
315        add_origen_env(&mut cmd);
316        log_trace!("Running Python command: '{:?}'", cmd);
317        cmd
318    }};
319}
320
321/// Execute the given Python code and capture its stdout
322pub fn run_and_capture_stdout(code: &str) -> Result<String> {
323    let mut cmd = python_cmd!(code);
324    cmd.stdout(Stdio::piped());
325
326    let output = cmd.output()?;
327    if output.status.success() {
328        Ok(String::from_utf8_lossy(&output.stdout).to_string())
329    } else {
330        bail!(
331            "Something went wrong running the operation '{}', the log may have more details",
332            code
333        )
334    }
335}
336
337/// Run silently with all STDOUT and STDERR handled by the given callback functions
338pub fn run_with_callbacks(
339    code: &str,
340    stdout_callback: Option<&mut dyn FnMut(&str)>,
341    stderr_callback: Option<&mut dyn FnMut(&str)>,
342) -> Result<()> {
343    use origen::utility::command_helpers::log_stdout_and_stderr;
344
345    let mut cmd = PYTHON_CONFIG.run_cmd(code)?;
346    // current_exe returns the Python process once it gets underway, so pass in the CLI
347    // location for Origen to use (used to resolve Origen config files)
348    if let Ok(p) = std::env::current_exe() {
349        cmd.arg(&format!("origen_cli={}", p.display()));
350    };
351    cmd.arg(&format!("origen_cli_version={}", built_info::PKG_VERSION));
352    cmd.stdout(Stdio::piped());
353    cmd.stderr(Stdio::piped());
354
355    add_origen_env(&mut cmd);
356
357    log_trace!("Running Python command: '{:?}'", cmd);
358
359    let mut process = cmd.spawn()?;
360
361    log_stdout_and_stderr(&mut process, stdout_callback, stderr_callback);
362
363    if process.wait()?.success() {
364        Ok(())
365    } else {
366        bail!(
367            "Something went wrong running the operation '{}', the log may have more details",
368            code
369        )
370    }
371}
372
373/// Adds any Origen-related environment settings to a command
374pub fn add_origen_env(cmd: &mut Command) {
375    if origen::STATUS.is_origen_present || origen::STATUS.is_app_in_origen_dev_mode {
376        // A source checkout needs the freshly compiled backends on the path:
377        // '_origen' is built into the pyapi target directory and
378        // '_origen_metal' is staged inside the origen_metal Python package.
379        // Prepend rather than overwrite; callers (notably CI) legitimately set
380        // PYTHONPATH themselves and silently discarding it makes 'origen exec'
381        // behave differently from running the same command directly.
382        let root = &origen::STATUS.origen_wksp_root;
383        let mut paths = vec![
384            root.join("rust").join("pyapi").join("target"),
385            root.join("python").join("origen_metal"),
386        ];
387        if let Some(inherited) = std::env::var_os("PYTHONPATH") {
388            paths.extend(std::env::split_paths(&inherited));
389        }
390        let mut deduped: Vec<std::path::PathBuf> = Vec::with_capacity(paths.len());
391        for path in paths {
392            if !deduped.contains(&path) {
393                deduped.push(path);
394            }
395        }
396        match std::env::join_paths(deduped) {
397            Ok(joined) => {
398                cmd.env("PYTHONPATH", joined);
399            }
400            Err(e) => {
401                log_debug!(
402                    "Could not compose PYTHONPATH for the Origen environment: {}",
403                    e
404                );
405            }
406        }
407    }
408}
409
410pub fn is_backend_origen_mod_missing_err(err: &origen::Error) -> bool {
411    err.to_string()
412        .contains("ModuleNotFoundError: No module named '_origen'")
413}
414
415/// Attempts to get the username and email, utilizing the full Origen environment (site config, etc.)
416pub fn get_current_user_and_email() -> Result<(String, String, String)> {
417    let out = run_and_capture_stdout("import origen; print('--user start--'); print(origen.current_user.first_name); print(origen.current_user.last_name); print(origen.current_user.email)")?;
418    let split = out.split_once("--user start--");
419    if let Some(s) = split {
420        let mut info = s.1.trim().split("\n");
421        let (fnm, lnm, email): (String, String, String);
422        if let Some(i) = info.next() {
423            fnm = i.trim().to_string();
424        } else {
425            bail!("Expected a first name when retrieving user info");
426        }
427        if let Some(i) = info.next() {
428            lnm = i.trim().to_string();
429        } else {
430            bail!("Expected a last name when retrieving user info");
431        }
432        if let Some(i) = info.next() {
433            email = i.trim().to_string();
434        } else {
435            bail!("Expected an email name when retrieving user info");
436        }
437        Ok((fnm, lnm, email))
438    } else {
439        bail!("Unable to find '--user start--' when retrieving user information");
440    }
441}
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446
447    #[test]
448    fn extract_version_works() {
449        assert_eq!(
450            Version::parse("2.7.15-a").unwrap(),
451            extract_version("Python 2.7.15+a\n").unwrap()
452        );
453        assert_eq!(
454            Version::parse("3.6.8").unwrap(),
455            extract_version("Python 3.6.8 \n").unwrap()
456        );
457        assert_eq!(
458            Version::parse("1.1.0-rc1").unwrap(),
459            extract_version("uv 1.1.0rc1\n").unwrap()
460        );
461    }
462}