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/// Env var that opts into building the MIGraphX Python bindings **from source**
106/// (AMDMIGraphX) — the only path on Arch/CachyOS, where no pip/system wheel
107/// exists. Off by default (the C++ core + onnxruntime MIGraphX EP cover ONNX
108/// inference). Values: `1`/`true`/`yes`/`on`.
109pub const MIGRAPHX_BUILD_PYTHON_ENV: &str = "MLSTACK_MIGRAPHX_BUILD_PYTHON";
110
111/// Optional env var overriding the AMDMIGraphX branch to build. The standalone
112/// python bindings must match the installed C++ core, so the default tracks the
113/// Arch `migraphx` package line.
114pub const MIGRAPHX_SOURCE_BRANCH_ENV: &str = "MLSTACK_MIGRAPHX_SOURCE_BRANCH";
115
116/// Default AMDMIGraphX branch (matches the Arch `migraphx 7.2.3` package).
117pub const AMDMIGRAPHX_DEFAULT_BRANCH: &str = "rocm-7.2.3";
118
119/// Upstream source for the MIGraphX Python bindings.
120pub const AMDMIGRAPHX_REPO: &str = "https://github.com/ROCm/AMDMIGraphX";
121
122/// Whether the user opted into building the Python bindings from source.
123pub fn build_python_source_requested() -> bool {
124    let v = std::env::var(MIGRAPHX_BUILD_PYTHON_ENV)
125        .unwrap_or_default()
126        .trim()
127        .to_ascii_lowercase();
128    matches!(v.as_str(), "1" | "true" | "yes" | "on")
129}
130
131/// The AMDMIGraphX branch to build: the `MLSTACK_MIGRAPHX_SOURCE_BRANCH` env
132/// override if set, else [`AMDMIGRAPHX_DEFAULT_BRANCH`].
133pub fn source_branch() -> String {
134    let v = std::env::var(MIGRAPHX_SOURCE_BRANCH_ENV)
135        .unwrap_or_default()
136        .trim()
137        .to_string();
138    if v.is_empty() {
139        AMDMIGRAPHX_DEFAULT_BRANCH.to_string()
140    } else {
141        v
142    }
143}
144
145/// The MIGraphX Python installer.
146pub struct MigraphxPythonInstaller {
147    config: MigraphxPythonConfig,
148}
149
150impl MigraphxPythonInstaller {
151    /// Create a new MIGraphX Python installer with the given config.
152    pub fn new(config: MigraphxPythonConfig) -> Self {
153        Self { config }
154    }
155
156    /// Create with default config.
157    pub fn with_defaults() -> Self {
158        Self::new(MigraphxPythonConfig::default())
159    }
160
161    // -----------------------------------------------------------------------
162    // Command construction (VAL-INSTALL-030)
163    // -----------------------------------------------------------------------
164
165    /// Construct the pip install command for MIGraphX Python.
166    ///
167    /// The original script runs:
168    /// `uv pip install migraphx` or `pip install migraphx`
169    /// With optional `--break-system-packages` for global installs.
170    pub fn build_install_command(&self) -> ShellCommand {
171        let use_break = self.config.method == InstallMethod::Global
172            || self.config.method == InstallMethod::Auto;
173
174        let mut args = vec!["-m".to_string(), "pip".to_string(), "install".to_string()];
175        if use_break {
176            args.push("--break-system-packages".to_string());
177        }
178        if self.config.force {
179            args.push("--force-reinstall".to_string());
180        }
181        args.push(PACKAGE_NAME.to_string());
182
183        ShellCommand {
184            program: self.config.python_bin.clone(),
185            args,
186            env: vec![],
187        }
188    }
189
190    /// Construct the uv pip install command (preferred method).
191    pub fn build_uv_install_command(&self) -> ShellCommand {
192        let mut args = vec!["pip".to_string(), "install".to_string()];
193        if self.config.force {
194            args.push("--force-reinstall".to_string());
195        }
196        args.push(PACKAGE_NAME.to_string());
197
198        ShellCommand {
199            program: "uv".to_string(),
200            args,
201            env: vec![
202                ("ROCM_PATH".to_string(), "/opt/rocm".to_string()),
203                ("AMD_LOG_LEVEL".to_string(), "0".to_string()),
204            ],
205        }
206    }
207
208    // -----------------------------------------------------------------------
209    // Opt-in source build (Arch/CachyOS — no pip/system wheel)
210    // -----------------------------------------------------------------------
211
212    /// Ordered shell commands to build the MIGraphX Python bindings **from
213    /// source** (AMDMIGraphX) against the installed `/opt/rocm` migraphx core.
214    ///
215    /// This is the only path to the standalone `migraphx` python API
216    /// (`migraphx.parse_onnx`) on Arch/CachyOS, where no pip/system wheel
217    /// exists. Opt-in only via [`MIGRAPHX_BUILD_PYTHON_ENV`] — off by default,
218    /// because the C++ core + onnxruntime `MIGraphXExecutionProvider` already
219    /// cover ONNX inference. Heavy build (~20-40 min).
220    ///
221    /// - `workdir`     — scratch dir for the clone + build (e.g. `/tmp/...`)
222    /// - `rocm_prefix` — `/opt/rocm`
223    /// - `gpu_arch`    — `gfx1100` (or detected `GPU_ARCH`)
224    /// - `branch`      — matching ROCm branch, e.g. `rocm-7.2.3`
225    pub fn build_source_commands(
226        &self,
227        workdir: &str,
228        rocm_prefix: &str,
229        gpu_arch: &str,
230        branch: &str,
231    ) -> Vec<ShellCommand> {
232        let src = format!("{workdir}/AMDMIGraphX");
233        let build = format!("{src}/build");
234        let clang = format!("{rocm_prefix}/llvm/bin/clang");
235        let clangxx = format!("{rocm_prefix}/llvm/bin/clang++");
236        vec![
237            // 1. Shallow clone of the matching ROCm branch.
238            ShellCommand {
239                program: "git".to_string(),
240                args: vec![
241                    "clone".to_string(),
242                    "--depth".to_string(),
243                    "1".to_string(),
244                    "--single-branch".to_string(),
245                    "--branch".to_string(),
246                    branch.to_string(),
247                    AMDMIGRAPHX_REPO.to_string(),
248                    src.clone(),
249                ],
250                env: vec![],
251            },
252            // 2. Configure: python on, against /opt/rocm, ROCm clang, GPU arch.
253            ShellCommand {
254                program: "cmake".to_string(),
255                args: vec![
256                    "-S".to_string(),
257                    src.clone(),
258                    "-B".to_string(),
259                    build.clone(),
260                    "-DMIGRAPHX_ENABLE_PYTHON=On".to_string(),
261                    format!("-DCMAKE_PREFIX_PATH={rocm_prefix}"),
262                    format!("-DGPU_TARGETS={gpu_arch}"),
263                    format!("-DCMAKE_C_COMPILER={clang}"),
264                    format!("-DCMAKE_CXX_COMPILER={clangxx}"),
265                    "-DCMAKE_BUILD_TYPE=Release".to_string(),
266                ],
267                env: vec![],
268            },
269            // 3. Build the python extension target.
270            ShellCommand {
271                program: "cmake".to_string(),
272                args: vec![
273                    "--build".to_string(),
274                    build,
275                    "--target".to_string(),
276                    "migraphx_py".to_string(),
277                    "-j".to_string(),
278                ],
279                env: vec![],
280            },
281            // 4. Install the built python package into the active interpreter.
282            ShellCommand {
283                program: self.config.python_bin.clone(),
284                args: vec![
285                    "-m".to_string(),
286                    "pip".to_string(),
287                    "install".to_string(),
288                    format!("{src}/python"),
289                ],
290                env: vec![],
291            },
292        ]
293    }
294
295    /// Construct the command to check if MIGraphX system package is installed.
296    pub fn build_migraphx_check_command(&self) -> ShellCommand {
297        ShellCommand {
298            program: "migraphx-driver".to_string(),
299            args: vec!["--version".to_string()],
300            env: vec![],
301        }
302    }
303
304    /// Construct the command to verify MIGraphX Python module import.
305    pub fn build_python_import_check(&self) -> ShellCommand {
306        ShellCommand {
307            program: self.config.python_bin.clone(),
308            args: vec![
309                "-c".to_string(),
310                "import migraphx; print(getattr(migraphx, '__version__', 'unknown'))".to_string(),
311            ],
312            env: vec![],
313        }
314    }
315
316    /// Get the package name.
317    pub fn package_name(&self) -> &'static str {
318        PACKAGE_NAME
319    }
320
321    // -----------------------------------------------------------------------
322    // Distro availability (Arch-specific handling)
323    // -----------------------------------------------------------------------
324
325    /// Check whether MIGraphX Python bindings are available on the given distro.
326    ///
327    /// On Arch-family distros, the `migraphx` pip wheel does not exist in PyPI
328    /// and there is no `python3-migraphx` system package. The installation
329    /// should be skipped on these distros.
330    pub fn is_available_on_distro(&self, distro: &DistroFacade) -> bool {
331        !matches!(distro.family(), DistroFamily::Arch)
332    }
333
334    /// Build a human-readable message explaining why MIGraphX Python bindings
335    /// are not available on the given distro. Returns `None` if available.
336    pub fn build_unavailable_message(&self, distro: &DistroFacade) -> Option<String> {
337        if self.is_available_on_distro(distro) {
338            return None;
339        }
340        Some(format!(
341            "MIGraphX Python bindings (pip install migraphx) are not available on {}. \
342             The migraphx pip wheel is not published for Arch-family distros. \
343             If you need Python bindings, consider using the ROCm Docker image \
344             or building MIGraphX from source with Python bindings enabled.",
345            distro.id()
346        ))
347    }
348}
349
350// ===========================================================================
351// Tests
352// ===========================================================================
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    // -----------------------------------------------------------------------
359    // VAL-INSTALL-030: MIGraphX Python correct pip command
360    // -----------------------------------------------------------------------
361
362    #[test]
363    fn test_package_name_matches_original_script() {
364        assert_eq!(
365            PACKAGE_NAME, "migraphx",
366            "Package name must match install_migraphx_python.sh package"
367        );
368    }
369
370    #[test]
371    fn test_install_command_auto_method() {
372        let installer = MigraphxPythonInstaller::new(MigraphxPythonConfig {
373            method: InstallMethod::Auto,
374            ..Default::default()
375        });
376        let cmd = installer.build_install_command();
377        assert_eq!(cmd.program, "python3");
378        assert!(cmd.args.contains(&"-m".to_string()));
379        assert!(cmd.args.contains(&"pip".to_string()));
380        assert!(cmd.args.contains(&"install".to_string()));
381        assert!(cmd.args.contains(&"migraphx".to_string()));
382        assert!(cmd.args.contains(&"--break-system-packages".to_string()));
383    }
384
385    #[test]
386    fn test_install_command_global_method() {
387        let installer = MigraphxPythonInstaller::new(MigraphxPythonConfig {
388            method: InstallMethod::Global,
389            ..Default::default()
390        });
391        let cmd = installer.build_install_command();
392        assert!(cmd.args.contains(&"--break-system-packages".to_string()));
393    }
394
395    #[test]
396    fn test_install_command_venv_method() {
397        let installer = MigraphxPythonInstaller::new(MigraphxPythonConfig {
398            method: InstallMethod::Venv,
399            ..Default::default()
400        });
401        let cmd = installer.build_install_command();
402        // Venv method should NOT include --break-system-packages
403        assert!(!cmd.args.contains(&"--break-system-packages".to_string()));
404        assert!(cmd.args.contains(&"migraphx".to_string()));
405    }
406
407    #[test]
408    fn test_install_command_with_force() {
409        let installer = MigraphxPythonInstaller::new(MigraphxPythonConfig {
410            force: true,
411            method: InstallMethod::Venv,
412            ..Default::default()
413        });
414        let cmd = installer.build_install_command();
415        assert!(cmd.args.contains(&"--force-reinstall".to_string()));
416    }
417
418    #[test]
419    fn test_install_command_string() {
420        let installer = MigraphxPythonInstaller::with_defaults();
421        let cmd = installer.build_install_command();
422        let s = cmd.to_command_string();
423        assert!(s.contains("python3"));
424        assert!(s.contains("pip install"));
425        assert!(s.contains("migraphx"));
426    }
427
428    #[test]
429    fn test_uv_install_command() {
430        let installer = MigraphxPythonInstaller::with_defaults();
431        let cmd = installer.build_uv_install_command();
432        assert_eq!(cmd.program, "uv");
433        assert!(cmd.args.contains(&"pip".to_string()));
434        assert!(cmd.args.contains(&"install".to_string()));
435        assert!(cmd.args.contains(&"migraphx".to_string()));
436        // Should have ROCm env vars
437        assert!(cmd.env.iter().any(|(k, _)| k == "ROCM_PATH"));
438        assert!(cmd.env.iter().any(|(k, _)| k == "AMD_LOG_LEVEL"));
439    }
440
441    #[test]
442    fn test_migraphx_check_command() {
443        let installer = MigraphxPythonInstaller::with_defaults();
444        let cmd = installer.build_migraphx_check_command();
445        assert_eq!(cmd.program, "migraphx-driver");
446        assert!(cmd.args.contains(&"--version".to_string()));
447    }
448
449    #[test]
450    fn test_python_import_check() {
451        let installer = MigraphxPythonInstaller::with_defaults();
452        let cmd = installer.build_python_import_check();
453        assert_eq!(cmd.program, "python3");
454        assert!(cmd.args.iter().any(|a| a.contains("import migraphx")));
455    }
456
457    // --- Arch-specific availability tests ---
458
459    #[test]
460    fn test_is_available_debian() {
461        use crate::platform::detection::{DistroInfo, PackageManager};
462        let installer = MigraphxPythonInstaller::with_defaults();
463        let distro = DistroFacade::from_info(DistroInfo {
464            id: "ubuntu".to_string(),
465            family: DistroFamily::Debian,
466            pkg_manager: PackageManager::Apt,
467            ..Default::default()
468        });
469        assert!(
470            installer.is_available_on_distro(&distro),
471            "MIGraphX Python should be available on Debian"
472        );
473    }
474
475    #[test]
476    fn test_is_available_rhel() {
477        use crate::platform::detection::{DistroInfo, PackageManager};
478        let installer = MigraphxPythonInstaller::with_defaults();
479        let distro = DistroFacade::from_info(DistroInfo {
480            id: "fedora".to_string(),
481            family: DistroFamily::Rhel,
482            pkg_manager: PackageManager::Dnf,
483            ..Default::default()
484        });
485        assert!(
486            installer.is_available_on_distro(&distro),
487            "MIGraphX Python should be available on RHEL"
488        );
489    }
490
491    #[test]
492    fn test_not_available_on_arch() {
493        use crate::platform::detection::{DistroInfo, PackageManager};
494        let installer = MigraphxPythonInstaller::with_defaults();
495        let distro = DistroFacade::from_info(DistroInfo {
496            id: "arch".to_string(),
497            family: DistroFamily::Arch,
498            pkg_manager: PackageManager::Pacman,
499            ..Default::default()
500        });
501        assert!(
502            !installer.is_available_on_distro(&distro),
503            "MIGraphX Python should NOT be available on Arch"
504        );
505    }
506
507    #[test]
508    fn test_not_available_on_cachyos() {
509        use crate::platform::detection::{DistroInfo, PackageManager};
510        let installer = MigraphxPythonInstaller::with_defaults();
511        let distro = DistroFacade::from_info(DistroInfo {
512            id: "cachyos".to_string(),
513            family: DistroFamily::Arch,
514            pkg_manager: PackageManager::Pacman,
515            ..Default::default()
516        });
517        assert!(
518            !installer.is_available_on_distro(&distro),
519            "MIGraphX Python should NOT be available on CachyOS"
520        );
521    }
522
523    #[test]
524    fn test_unavailable_message_debian_is_none() {
525        use crate::platform::detection::{DistroInfo, PackageManager};
526        let installer = MigraphxPythonInstaller::with_defaults();
527        let distro = DistroFacade::from_info(DistroInfo {
528            id: "ubuntu".to_string(),
529            family: DistroFamily::Debian,
530            pkg_manager: PackageManager::Apt,
531            ..Default::default()
532        });
533        assert!(
534            installer.build_unavailable_message(&distro).is_none(),
535            "Debian should not have an unavailable message"
536        );
537    }
538
539    #[test]
540    fn test_unavailable_message_arch_is_informative() {
541        use crate::platform::detection::{DistroInfo, PackageManager};
542        let installer = MigraphxPythonInstaller::with_defaults();
543        let distro = DistroFacade::from_info(DistroInfo {
544            id: "arch".to_string(),
545            family: DistroFamily::Arch,
546            pkg_manager: PackageManager::Pacman,
547            ..Default::default()
548        });
549        let msg = installer
550            .build_unavailable_message(&distro)
551            .expect("Arch should have an unavailable message");
552        assert!(
553            msg.contains("not available"),
554            "Message should say not available: {msg}"
555        );
556        assert!(msg.contains("Arch"), "Message should mention Arch: {msg}");
557        assert!(msg.contains("pip"), "Message should mention pip: {msg}");
558        assert!(
559            msg.contains("Docker") || msg.contains("source"),
560            "Message should suggest alternatives: {msg}"
561        );
562    }
563
564    // --- Opt-in source build ---
565
566    #[test]
567    fn test_build_python_source_requested_env_gated() {
568        let _env = crate::test_support::lock_env();
569        std::env::remove_var(MIGRAPHX_BUILD_PYTHON_ENV);
570        assert!(!build_python_source_requested(), "default must be OFF");
571        for v in ["1", "true", "TRUE", "yes", "on"] {
572            std::env::set_var(MIGRAPHX_BUILD_PYTHON_ENV, v);
573            assert!(build_python_source_requested(), "should be ON for {v:?}");
574        }
575        std::env::set_var(MIGRAPHX_BUILD_PYTHON_ENV, "0");
576        assert!(!build_python_source_requested());
577    }
578
579    #[test]
580    fn test_source_branch_default_and_override() {
581        let _env = crate::test_support::lock_env();
582        std::env::remove_var(MIGRAPHX_SOURCE_BRANCH_ENV);
583        assert_eq!(source_branch(), AMDMIGRAPHX_DEFAULT_BRANCH);
584        std::env::set_var(MIGRAPHX_SOURCE_BRANCH_ENV, "rocm-7.2.4");
585        assert_eq!(source_branch(), "rocm-7.2.4");
586    }
587
588    #[test]
589    fn test_build_source_commands_sequence() {
590        let inst = MigraphxPythonInstaller::new(MigraphxPythonConfig {
591            python_bin: "/venv/bin/python".to_string(),
592            ..Default::default()
593        });
594        let cmds = inst.build_source_commands("/tmp/wd", "/opt/rocm", "gfx1100", "rocm-7.2.3");
595        assert_eq!(
596            cmds.len(),
597            4,
598            "expected: clone, configure, build, pip install"
599        );
600
601        // 1. shallow clone of the matching ROCm branch
602        assert_eq!(cmds[0].program, "git");
603        assert!(cmds[0].args.contains(&"clone".to_string()));
604        assert!(cmds[0].args.contains(&"--branch".to_string()));
605        assert!(cmds[0].args.contains(&"rocm-7.2.3".to_string()));
606        assert!(cmds[0].args.contains(&AMDMIGRAPHX_REPO.to_string()));
607
608        // 2. configure: python on, ROCm clang, GPU arch, /opt/rocm prefix
609        assert_eq!(cmds[1].program, "cmake");
610        assert!(cmds[1]
611            .args
612            .contains(&"-DMIGRAPHX_ENABLE_PYTHON=On".to_string()));
613        assert!(cmds[1]
614            .args
615            .contains(&"-DCMAKE_PREFIX_PATH=/opt/rocm".to_string()));
616        assert!(cmds[1].args.contains(&"-DGPU_TARGETS=gfx1100".to_string()));
617        assert!(cmds[1]
618            .args
619            .contains(&"-DCMAKE_CXX_COMPILER=/opt/rocm/llvm/bin/clang++".to_string()));
620
621        // 3. build the python extension target
622        assert_eq!(cmds[2].program, "cmake");
623        assert!(cmds[2].args.contains(&"--target".to_string()));
624        assert!(cmds[2].args.contains(&"migraphx_py".to_string()));
625
626        // 4. pip install the built package into the configured interpreter
627        assert_eq!(cmds[3].program, "/venv/bin/python");
628        assert!(cmds[3].args.contains(&"install".to_string()));
629        assert!(cmds[3]
630            .args
631            .iter()
632            .any(|a| a.ends_with("/AMDMIGraphX/python")));
633    }
634}