Skip to main content

rusty_stack/installers/components/
migraphx_python.rs

1//! MIGraphX Python bindings installer — ports `scripts/install_migraphx_python.sh`.
2//!
3//! Installs the MIGraphX Python module via pip. Requires MIGraphX system
4//! package to be installed first.
5//!
6//! # Arch Linux / CachyOS Handling
7//!
8//! On Arch-family distros, the `migraphx` pip wheel is not available.
9//! The installer should be skipped on these distros with a clear message
10//! informing the user. Use `is_available_on_distro()` to check before
11//! attempting installation.
12//!
13//! # Validation Assertions
14//!
15//! - **VAL-INSTALL-030**: MIGraphX Python correct pip command
16
17use crate::installers::common::DistroFacade;
18use crate::platform::detection::DistroFamily;
19
20// ===========================================================================
21// Types
22// ===========================================================================
23
24/// Installation method.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum InstallMethod {
27    /// Install globally.
28    Global,
29    /// Install in a virtual environment.
30    Venv,
31    /// Try global, fallback to venv.
32    Auto,
33}
34
35impl std::fmt::Display for InstallMethod {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            InstallMethod::Global => write!(f, "global"),
39            InstallMethod::Venv => write!(f, "venv"),
40            InstallMethod::Auto => write!(f, "auto"),
41        }
42    }
43}
44
45/// A constructed shell command.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct ShellCommand {
48    /// The program to run.
49    pub program: String,
50    /// Arguments to pass.
51    pub args: Vec<String>,
52    /// Environment variables to set.
53    pub env: Vec<(String, String)>,
54}
55
56impl ShellCommand {
57    /// Format as a shell command string.
58    pub fn to_command_string(&self) -> String {
59        let env_prefix = self
60            .env
61            .iter()
62            .map(|(k, v)| format!("{k}={v}"))
63            .collect::<Vec<_>>()
64            .join(" ");
65        let cmd = if self.args.is_empty() {
66            self.program.clone()
67        } else {
68            format!("{} {}", self.program, self.args.join(" "))
69        };
70        if env_prefix.is_empty() {
71            cmd
72        } else {
73            format!("{env_prefix} {cmd}")
74        }
75    }
76}
77
78/// Configuration for the MIGraphX Python installer.
79#[derive(Debug, Clone)]
80pub struct MigraphxPythonConfig {
81    /// Python binary to use.
82    pub python_bin: String,
83    /// Installation method.
84    pub method: InstallMethod,
85    /// Whether to run in dry-run mode.
86    pub dry_run: bool,
87    /// Whether to force reinstall.
88    pub force: bool,
89}
90
91impl Default for MigraphxPythonConfig {
92    fn default() -> Self {
93        Self {
94            python_bin: "python3".to_string(),
95            method: InstallMethod::Auto,
96            dry_run: false,
97            force: false,
98        }
99    }
100}
101
102/// Package name for MIGraphX Python bindings.
103pub const PACKAGE_NAME: &str = "migraphx";
104
105/// The MIGraphX Python installer.
106pub struct MigraphxPythonInstaller {
107    config: MigraphxPythonConfig,
108}
109
110impl MigraphxPythonInstaller {
111    /// Create a new MIGraphX Python installer with the given config.
112    pub fn new(config: MigraphxPythonConfig) -> Self {
113        Self { config }
114    }
115
116    /// Create with default config.
117    pub fn with_defaults() -> Self {
118        Self::new(MigraphxPythonConfig::default())
119    }
120
121    // -----------------------------------------------------------------------
122    // Command construction (VAL-INSTALL-030)
123    // -----------------------------------------------------------------------
124
125    /// Construct the pip install command for MIGraphX Python.
126    ///
127    /// The original script runs:
128    /// `uv pip install migraphx` or `pip install migraphx`
129    /// With optional `--break-system-packages` for global installs.
130    pub fn build_install_command(&self) -> ShellCommand {
131        let use_break = self.config.method == InstallMethod::Global
132            || self.config.method == InstallMethod::Auto;
133
134        let mut args = vec!["-m".to_string(), "pip".to_string(), "install".to_string()];
135        if use_break {
136            args.push("--break-system-packages".to_string());
137        }
138        if self.config.force {
139            args.push("--force-reinstall".to_string());
140        }
141        args.push(PACKAGE_NAME.to_string());
142
143        ShellCommand {
144            program: self.config.python_bin.clone(),
145            args,
146            env: vec![],
147        }
148    }
149
150    /// Construct the uv pip install command (preferred method).
151    pub fn build_uv_install_command(&self) -> ShellCommand {
152        let mut args = vec!["pip".to_string(), "install".to_string()];
153        if self.config.force {
154            args.push("--force-reinstall".to_string());
155        }
156        args.push(PACKAGE_NAME.to_string());
157
158        ShellCommand {
159            program: "uv".to_string(),
160            args,
161            env: vec![
162                ("ROCM_PATH".to_string(), "/opt/rocm".to_string()),
163                ("AMD_LOG_LEVEL".to_string(), "0".to_string()),
164            ],
165        }
166    }
167
168    /// Construct the command to check if MIGraphX system package is installed.
169    pub fn build_migraphx_check_command(&self) -> ShellCommand {
170        ShellCommand {
171            program: "migraphx-driver".to_string(),
172            args: vec!["--version".to_string()],
173            env: vec![],
174        }
175    }
176
177    /// Construct the command to verify MIGraphX Python module import.
178    pub fn build_python_import_check(&self) -> ShellCommand {
179        ShellCommand {
180            program: self.config.python_bin.clone(),
181            args: vec![
182                "-c".to_string(),
183                "import migraphx; print(getattr(migraphx, '__version__', 'unknown'))".to_string(),
184            ],
185            env: vec![],
186        }
187    }
188
189    /// Construct the ROCm environment setup for the install.
190    ///
191    /// Stage 1: never trust inherited `HIP_VISIBLE_DEVICES` verbatim — the
192    /// parent env may carry the iGPU index (the root cause of the LeIndex
193    /// ONNX/MIGraphX worker failure). Re-derive the dGPU list from the
194    /// canonical discrete detector; only honor an inherited value when it is a
195    /// strict subset of the detected dGPUs (preserves intentional narrowing).
196    ///
197    /// A visibility mask is emitted ONLY when at least one discrete GPU is
198    /// confirmed. When no dGPU is detected (iGPU-only / detection failure) the
199    /// `HIP_VISIBLE_DEVICES`/`ROCR_VISIBLE_DEVICES` keys are left unset so the
200    /// process inherits the caller's environment — never leaking a default
201    /// `"0"` (which would make the iGPU the only visible device).
202    pub fn build_rocm_env(&self) -> Vec<(String, String)> {
203        let detected: Vec<String> = crate::installer::detect_discrete_gpus();
204        let detected_set: std::collections::HashSet<&str> =
205            detected.iter().map(|s| s.as_str()).collect();
206
207        let gpu_visible = std::env::var("HIP_VISIBLE_DEVICES")
208            .or_else(|_| std::env::var("CUDA_VISIBLE_DEVICES"))
209            .ok()
210            .and_then(|v| {
211                let inherited: Vec<&str> = v
212                    .split(',')
213                    .map(str::trim)
214                    .filter(|s| !s.is_empty())
215                    .collect();
216                let all_subset = !inherited.is_empty()
217                    && inherited.iter().all(|idx| detected_set.contains(*idx));
218                if all_subset {
219                    Some(inherited.join(","))
220                } else {
221                    None
222                }
223            })
224            .or_else(|| {
225                if detected.is_empty() {
226                    None
227                } else {
228                    Some(detected.join(","))
229                }
230            });
231
232        let mut env = vec![
233            ("ROCM_PATH".to_string(), "/opt/rocm".to_string()),
234            ("AMD_LOG_LEVEL".to_string(), "0".to_string()),
235        ];
236        if let Some(visible) = gpu_visible {
237            env.push(("HIP_VISIBLE_DEVICES".to_string(), visible.clone()));
238            env.push(("ROCR_VISIBLE_DEVICES".to_string(), visible));
239        }
240        env
241    }
242
243    /// Get the package name.
244    pub fn package_name(&self) -> &'static str {
245        PACKAGE_NAME
246    }
247
248    // -----------------------------------------------------------------------
249    // Distro availability (Arch-specific handling)
250    // -----------------------------------------------------------------------
251
252    /// Check whether MIGraphX Python bindings are available on the given distro.
253    ///
254    /// On Arch-family distros, the `migraphx` pip wheel does not exist in PyPI
255    /// and there is no `python3-migraphx` system package. The installation
256    /// should be skipped on these distros.
257    pub fn is_available_on_distro(&self, distro: &DistroFacade) -> bool {
258        !matches!(distro.family(), DistroFamily::Arch)
259    }
260
261    /// Build a human-readable message explaining why MIGraphX Python bindings
262    /// are not available on the given distro. Returns `None` if available.
263    pub fn build_unavailable_message(&self, distro: &DistroFacade) -> Option<String> {
264        if self.is_available_on_distro(distro) {
265            return None;
266        }
267        Some(format!(
268            "MIGraphX Python bindings (pip install migraphx) are not available on {}. \
269             The migraphx pip wheel is not published for Arch-family distros. \
270             If you need Python bindings, consider using the ROCm Docker image \
271             or building MIGraphX from source with Python bindings enabled.",
272            distro.id()
273        ))
274    }
275}
276
277// ===========================================================================
278// Tests
279// ===========================================================================
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    // -----------------------------------------------------------------------
286    // VAL-INSTALL-030: MIGraphX Python correct pip command
287    // -----------------------------------------------------------------------
288
289    #[test]
290    fn test_package_name_matches_original_script() {
291        assert_eq!(
292            PACKAGE_NAME, "migraphx",
293            "Package name must match install_migraphx_python.sh package"
294        );
295    }
296
297    #[test]
298    fn test_install_command_auto_method() {
299        let installer = MigraphxPythonInstaller::new(MigraphxPythonConfig {
300            method: InstallMethod::Auto,
301            ..Default::default()
302        });
303        let cmd = installer.build_install_command();
304        assert_eq!(cmd.program, "python3");
305        assert!(cmd.args.contains(&"-m".to_string()));
306        assert!(cmd.args.contains(&"pip".to_string()));
307        assert!(cmd.args.contains(&"install".to_string()));
308        assert!(cmd.args.contains(&"migraphx".to_string()));
309        assert!(cmd.args.contains(&"--break-system-packages".to_string()));
310    }
311
312    #[test]
313    fn test_install_command_global_method() {
314        let installer = MigraphxPythonInstaller::new(MigraphxPythonConfig {
315            method: InstallMethod::Global,
316            ..Default::default()
317        });
318        let cmd = installer.build_install_command();
319        assert!(cmd.args.contains(&"--break-system-packages".to_string()));
320    }
321
322    #[test]
323    fn test_install_command_venv_method() {
324        let installer = MigraphxPythonInstaller::new(MigraphxPythonConfig {
325            method: InstallMethod::Venv,
326            ..Default::default()
327        });
328        let cmd = installer.build_install_command();
329        // Venv method should NOT include --break-system-packages
330        assert!(!cmd.args.contains(&"--break-system-packages".to_string()));
331        assert!(cmd.args.contains(&"migraphx".to_string()));
332    }
333
334    #[test]
335    fn test_install_command_with_force() {
336        let installer = MigraphxPythonInstaller::new(MigraphxPythonConfig {
337            force: true,
338            method: InstallMethod::Venv,
339            ..Default::default()
340        });
341        let cmd = installer.build_install_command();
342        assert!(cmd.args.contains(&"--force-reinstall".to_string()));
343    }
344
345    #[test]
346    fn test_install_command_string() {
347        let installer = MigraphxPythonInstaller::with_defaults();
348        let cmd = installer.build_install_command();
349        let s = cmd.to_command_string();
350        assert!(s.contains("python3"));
351        assert!(s.contains("pip install"));
352        assert!(s.contains("migraphx"));
353    }
354
355    #[test]
356    fn test_uv_install_command() {
357        let installer = MigraphxPythonInstaller::with_defaults();
358        let cmd = installer.build_uv_install_command();
359        assert_eq!(cmd.program, "uv");
360        assert!(cmd.args.contains(&"pip".to_string()));
361        assert!(cmd.args.contains(&"install".to_string()));
362        assert!(cmd.args.contains(&"migraphx".to_string()));
363        // Should have ROCm env vars
364        assert!(cmd.env.iter().any(|(k, _)| k == "ROCM_PATH"));
365        assert!(cmd.env.iter().any(|(k, _)| k == "AMD_LOG_LEVEL"));
366    }
367
368    #[test]
369    fn test_migraphx_check_command() {
370        let installer = MigraphxPythonInstaller::with_defaults();
371        let cmd = installer.build_migraphx_check_command();
372        assert_eq!(cmd.program, "migraphx-driver");
373        assert!(cmd.args.contains(&"--version".to_string()));
374    }
375
376    #[test]
377    fn test_python_import_check() {
378        let installer = MigraphxPythonInstaller::with_defaults();
379        let cmd = installer.build_python_import_check();
380        assert_eq!(cmd.program, "python3");
381        assert!(cmd.args.iter().any(|a| a.contains("import migraphx")));
382    }
383
384    #[test]
385    fn test_rocm_env() {
386        let installer = MigraphxPythonInstaller::with_defaults();
387        let env = installer.build_rocm_env();
388        assert!(env
389            .iter()
390            .any(|(k, v)| k == "ROCM_PATH" && v == "/opt/rocm"));
391        assert!(env.iter().any(|(k, v)| k == "AMD_LOG_LEVEL" && v == "0"));
392    }
393
394    // --- Arch-specific availability tests ---
395
396    #[test]
397    fn test_is_available_debian() {
398        use crate::platform::detection::{DistroInfo, PackageManager};
399        let installer = MigraphxPythonInstaller::with_defaults();
400        let distro = DistroFacade::from_info(DistroInfo {
401            id: "ubuntu".to_string(),
402            family: DistroFamily::Debian,
403            pkg_manager: PackageManager::Apt,
404            ..Default::default()
405        });
406        assert!(
407            installer.is_available_on_distro(&distro),
408            "MIGraphX Python should be available on Debian"
409        );
410    }
411
412    #[test]
413    fn test_is_available_rhel() {
414        use crate::platform::detection::{DistroInfo, PackageManager};
415        let installer = MigraphxPythonInstaller::with_defaults();
416        let distro = DistroFacade::from_info(DistroInfo {
417            id: "fedora".to_string(),
418            family: DistroFamily::Rhel,
419            pkg_manager: PackageManager::Dnf,
420            ..Default::default()
421        });
422        assert!(
423            installer.is_available_on_distro(&distro),
424            "MIGraphX Python should be available on RHEL"
425        );
426    }
427
428    #[test]
429    fn test_not_available_on_arch() {
430        use crate::platform::detection::{DistroInfo, PackageManager};
431        let installer = MigraphxPythonInstaller::with_defaults();
432        let distro = DistroFacade::from_info(DistroInfo {
433            id: "arch".to_string(),
434            family: DistroFamily::Arch,
435            pkg_manager: PackageManager::Pacman,
436            ..Default::default()
437        });
438        assert!(
439            !installer.is_available_on_distro(&distro),
440            "MIGraphX Python should NOT be available on Arch"
441        );
442    }
443
444    #[test]
445    fn test_not_available_on_cachyos() {
446        use crate::platform::detection::{DistroInfo, PackageManager};
447        let installer = MigraphxPythonInstaller::with_defaults();
448        let distro = DistroFacade::from_info(DistroInfo {
449            id: "cachyos".to_string(),
450            family: DistroFamily::Arch,
451            pkg_manager: PackageManager::Pacman,
452            ..Default::default()
453        });
454        assert!(
455            !installer.is_available_on_distro(&distro),
456            "MIGraphX Python should NOT be available on CachyOS"
457        );
458    }
459
460    #[test]
461    fn test_unavailable_message_debian_is_none() {
462        use crate::platform::detection::{DistroInfo, PackageManager};
463        let installer = MigraphxPythonInstaller::with_defaults();
464        let distro = DistroFacade::from_info(DistroInfo {
465            id: "ubuntu".to_string(),
466            family: DistroFamily::Debian,
467            pkg_manager: PackageManager::Apt,
468            ..Default::default()
469        });
470        assert!(
471            installer.build_unavailable_message(&distro).is_none(),
472            "Debian should not have an unavailable message"
473        );
474    }
475
476    #[test]
477    fn test_unavailable_message_arch_is_informative() {
478        use crate::platform::detection::{DistroInfo, PackageManager};
479        let installer = MigraphxPythonInstaller::with_defaults();
480        let distro = DistroFacade::from_info(DistroInfo {
481            id: "arch".to_string(),
482            family: DistroFamily::Arch,
483            pkg_manager: PackageManager::Pacman,
484            ..Default::default()
485        });
486        let msg = installer
487            .build_unavailable_message(&distro)
488            .expect("Arch should have an unavailable message");
489        assert!(
490            msg.contains("not available"),
491            "Message should say not available: {msg}"
492        );
493        assert!(msg.contains("Arch"), "Message should mention Arch: {msg}");
494        assert!(msg.contains("pip"), "Message should mention pip: {msg}");
495        assert!(
496            msg.contains("Docker") || msg.contains("source"),
497            "Message should suggest alternatives: {msg}"
498        );
499    }
500}