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    pub fn build_rocm_env(&self) -> Vec<(String, String)> {
191        let gpu_visible = std::env::var("HIP_VISIBLE_DEVICES")
192            .or_else(|_| std::env::var("CUDA_VISIBLE_DEVICES"))
193            .unwrap_or_else(|_| "0".to_string());
194
195        vec![
196            ("ROCM_PATH".to_string(), "/opt/rocm".to_string()),
197            ("AMD_LOG_LEVEL".to_string(), "0".to_string()),
198            ("HIP_VISIBLE_DEVICES".to_string(), gpu_visible.clone()),
199            ("ROCR_VISIBLE_DEVICES".to_string(), gpu_visible),
200        ]
201    }
202
203    /// Get the package name.
204    pub fn package_name(&self) -> &'static str {
205        PACKAGE_NAME
206    }
207
208    // -----------------------------------------------------------------------
209    // Distro availability (Arch-specific handling)
210    // -----------------------------------------------------------------------
211
212    /// Check whether MIGraphX Python bindings are available on the given distro.
213    ///
214    /// On Arch-family distros, the `migraphx` pip wheel does not exist in PyPI
215    /// and there is no `python3-migraphx` system package. The installation
216    /// should be skipped on these distros.
217    pub fn is_available_on_distro(&self, distro: &DistroFacade) -> bool {
218        !matches!(distro.family(), DistroFamily::Arch)
219    }
220
221    /// Build a human-readable message explaining why MIGraphX Python bindings
222    /// are not available on the given distro. Returns `None` if available.
223    pub fn build_unavailable_message(&self, distro: &DistroFacade) -> Option<String> {
224        if self.is_available_on_distro(distro) {
225            return None;
226        }
227        Some(format!(
228            "MIGraphX Python bindings (pip install migraphx) are not available on {}. \
229             The migraphx pip wheel is not published for Arch-family distros. \
230             If you need Python bindings, consider using the ROCm Docker image \
231             or building MIGraphX from source with Python bindings enabled.",
232            distro.id()
233        ))
234    }
235}
236
237// ===========================================================================
238// Tests
239// ===========================================================================
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244
245    // -----------------------------------------------------------------------
246    // VAL-INSTALL-030: MIGraphX Python correct pip command
247    // -----------------------------------------------------------------------
248
249    #[test]
250    fn test_package_name_matches_original_script() {
251        assert_eq!(
252            PACKAGE_NAME, "migraphx",
253            "Package name must match install_migraphx_python.sh package"
254        );
255    }
256
257    #[test]
258    fn test_install_command_auto_method() {
259        let installer = MigraphxPythonInstaller::new(MigraphxPythonConfig {
260            method: InstallMethod::Auto,
261            ..Default::default()
262        });
263        let cmd = installer.build_install_command();
264        assert_eq!(cmd.program, "python3");
265        assert!(cmd.args.contains(&"-m".to_string()));
266        assert!(cmd.args.contains(&"pip".to_string()));
267        assert!(cmd.args.contains(&"install".to_string()));
268        assert!(cmd.args.contains(&"migraphx".to_string()));
269        assert!(cmd.args.contains(&"--break-system-packages".to_string()));
270    }
271
272    #[test]
273    fn test_install_command_global_method() {
274        let installer = MigraphxPythonInstaller::new(MigraphxPythonConfig {
275            method: InstallMethod::Global,
276            ..Default::default()
277        });
278        let cmd = installer.build_install_command();
279        assert!(cmd.args.contains(&"--break-system-packages".to_string()));
280    }
281
282    #[test]
283    fn test_install_command_venv_method() {
284        let installer = MigraphxPythonInstaller::new(MigraphxPythonConfig {
285            method: InstallMethod::Venv,
286            ..Default::default()
287        });
288        let cmd = installer.build_install_command();
289        // Venv method should NOT include --break-system-packages
290        assert!(!cmd.args.contains(&"--break-system-packages".to_string()));
291        assert!(cmd.args.contains(&"migraphx".to_string()));
292    }
293
294    #[test]
295    fn test_install_command_with_force() {
296        let installer = MigraphxPythonInstaller::new(MigraphxPythonConfig {
297            force: true,
298            method: InstallMethod::Venv,
299            ..Default::default()
300        });
301        let cmd = installer.build_install_command();
302        assert!(cmd.args.contains(&"--force-reinstall".to_string()));
303    }
304
305    #[test]
306    fn test_install_command_string() {
307        let installer = MigraphxPythonInstaller::with_defaults();
308        let cmd = installer.build_install_command();
309        let s = cmd.to_command_string();
310        assert!(s.contains("python3"));
311        assert!(s.contains("pip install"));
312        assert!(s.contains("migraphx"));
313    }
314
315    #[test]
316    fn test_uv_install_command() {
317        let installer = MigraphxPythonInstaller::with_defaults();
318        let cmd = installer.build_uv_install_command();
319        assert_eq!(cmd.program, "uv");
320        assert!(cmd.args.contains(&"pip".to_string()));
321        assert!(cmd.args.contains(&"install".to_string()));
322        assert!(cmd.args.contains(&"migraphx".to_string()));
323        // Should have ROCm env vars
324        assert!(cmd.env.iter().any(|(k, _)| k == "ROCM_PATH"));
325        assert!(cmd.env.iter().any(|(k, _)| k == "AMD_LOG_LEVEL"));
326    }
327
328    #[test]
329    fn test_migraphx_check_command() {
330        let installer = MigraphxPythonInstaller::with_defaults();
331        let cmd = installer.build_migraphx_check_command();
332        assert_eq!(cmd.program, "migraphx-driver");
333        assert!(cmd.args.contains(&"--version".to_string()));
334    }
335
336    #[test]
337    fn test_python_import_check() {
338        let installer = MigraphxPythonInstaller::with_defaults();
339        let cmd = installer.build_python_import_check();
340        assert_eq!(cmd.program, "python3");
341        assert!(cmd.args.iter().any(|a| a.contains("import migraphx")));
342    }
343
344    #[test]
345    fn test_rocm_env() {
346        let installer = MigraphxPythonInstaller::with_defaults();
347        let env = installer.build_rocm_env();
348        assert!(env
349            .iter()
350            .any(|(k, v)| k == "ROCM_PATH" && v == "/opt/rocm"));
351        assert!(env.iter().any(|(k, v)| k == "AMD_LOG_LEVEL" && v == "0"));
352    }
353
354    // --- Arch-specific availability tests ---
355
356    #[test]
357    fn test_is_available_debian() {
358        use crate::platform::detection::{DistroInfo, PackageManager};
359        let installer = MigraphxPythonInstaller::with_defaults();
360        let distro = DistroFacade::from_info(DistroInfo {
361            id: "ubuntu".to_string(),
362            family: DistroFamily::Debian,
363            pkg_manager: PackageManager::Apt,
364            ..Default::default()
365        });
366        assert!(
367            installer.is_available_on_distro(&distro),
368            "MIGraphX Python should be available on Debian"
369        );
370    }
371
372    #[test]
373    fn test_is_available_rhel() {
374        use crate::platform::detection::{DistroInfo, PackageManager};
375        let installer = MigraphxPythonInstaller::with_defaults();
376        let distro = DistroFacade::from_info(DistroInfo {
377            id: "fedora".to_string(),
378            family: DistroFamily::Rhel,
379            pkg_manager: PackageManager::Dnf,
380            ..Default::default()
381        });
382        assert!(
383            installer.is_available_on_distro(&distro),
384            "MIGraphX Python should be available on RHEL"
385        );
386    }
387
388    #[test]
389    fn test_not_available_on_arch() {
390        use crate::platform::detection::{DistroInfo, PackageManager};
391        let installer = MigraphxPythonInstaller::with_defaults();
392        let distro = DistroFacade::from_info(DistroInfo {
393            id: "arch".to_string(),
394            family: DistroFamily::Arch,
395            pkg_manager: PackageManager::Pacman,
396            ..Default::default()
397        });
398        assert!(
399            !installer.is_available_on_distro(&distro),
400            "MIGraphX Python should NOT be available on Arch"
401        );
402    }
403
404    #[test]
405    fn test_not_available_on_cachyos() {
406        use crate::platform::detection::{DistroInfo, PackageManager};
407        let installer = MigraphxPythonInstaller::with_defaults();
408        let distro = DistroFacade::from_info(DistroInfo {
409            id: "cachyos".to_string(),
410            family: DistroFamily::Arch,
411            pkg_manager: PackageManager::Pacman,
412            ..Default::default()
413        });
414        assert!(
415            !installer.is_available_on_distro(&distro),
416            "MIGraphX Python should NOT be available on CachyOS"
417        );
418    }
419
420    #[test]
421    fn test_unavailable_message_debian_is_none() {
422        use crate::platform::detection::{DistroInfo, PackageManager};
423        let installer = MigraphxPythonInstaller::with_defaults();
424        let distro = DistroFacade::from_info(DistroInfo {
425            id: "ubuntu".to_string(),
426            family: DistroFamily::Debian,
427            pkg_manager: PackageManager::Apt,
428            ..Default::default()
429        });
430        assert!(
431            installer.build_unavailable_message(&distro).is_none(),
432            "Debian should not have an unavailable message"
433        );
434    }
435
436    #[test]
437    fn test_unavailable_message_arch_is_informative() {
438        use crate::platform::detection::{DistroInfo, PackageManager};
439        let installer = MigraphxPythonInstaller::with_defaults();
440        let distro = DistroFacade::from_info(DistroInfo {
441            id: "arch".to_string(),
442            family: DistroFamily::Arch,
443            pkg_manager: PackageManager::Pacman,
444            ..Default::default()
445        });
446        let msg = installer
447            .build_unavailable_message(&distro)
448            .expect("Arch should have an unavailable message");
449        assert!(
450            msg.contains("not available"),
451            "Message should say not available: {msg}"
452        );
453        assert!(msg.contains("Arch"), "Message should mention Arch: {msg}");
454        assert!(msg.contains("pip"), "Message should mention pip: {msg}");
455        assert!(
456            msg.contains("Docker") || msg.contains("source"),
457            "Message should suggest alternatives: {msg}"
458        );
459    }
460}