mecha10-cli 0.7.0

Mecha10 CLI tool
Documentation
//! Resolves the Python interpreter used to run `mecha10_rl.mujoco.cli`, and a fast preflight
//! check for whether `mecha10_rl` is actually importable by it.
//!
//! This is a small, intentional duplicate of `mecha10-nodes-simulator::python_env`'s own copy of
//! these same three functions (`mecha-industries/mecha10-nodes`, `simulator/src/python_env.rs`)
//! -- `packages/cli` used to depend on that crate as a Cargo git dependency purely to reuse
//! them (see `handlers/dev/ops/preflight.rs` and `sim/mod.rs`), which was `mecha10`'s last
//! compile-time dependency on `mecha10-nodes` once the node-schema aggregation path
//! (`mecha10-schema-registry`) was made API-driven instead. These functions are self-contained
//! (plain `std::path`/subprocess logic, no dependency on the rest of the simulator crate), so
//! relocating a copy here breaks that cross-repo compile-time dependency entirely; the
//! `mecha10-nodes-simulator` crate keeps its own copy for its own runtime use.

use std::path::Path;
use tokio::process::Command;

pub const DEFAULT_PYTHON_EXECUTABLE: &str = "python3";

/// Resolve the Python interpreter to invoke for the `mecha10_rl.mujoco.cli` subprocess.
///
/// If `configured` is [`DEFAULT_PYTHON_EXECUTABLE`] (the only value this is ever called with in
/// production - there is no config override for this), prefer a project-local virtualenv
/// interpreter at `<project_root>/.venv/bin/python` (`.venv\Scripts\python.exe` on Windows) when
/// it exists. That's the same convention `mecha10 setup` creates
/// (`packages/cli/src/handlers/setup.rs::setup_venv`) - a much likelier place to find
/// `mecha10_rl` installed than the bare system `python3` on `PATH`.
///
/// Falls back to `configured` unchanged when no such venv exists, or when `configured` is
/// anything other than the default string (only ever exercised by this module's own tests).
pub fn resolve_python_executable(configured: &str, project_root: &Path) -> String {
    if configured != DEFAULT_PYTHON_EXECUTABLE {
        return configured.to_string();
    }

    let venv_python = if cfg!(windows) {
        project_root.join(".venv").join("Scripts").join("python.exe")
    } else {
        project_root.join(".venv").join("bin").join("python")
    };

    if venv_python.exists() {
        venv_python.display().to_string()
    } else {
        configured.to_string()
    }
}

/// Run a quick `<python> -c "import mecha10_rl.mujoco.cli"` preflight check, distinguishing a
/// permanent environment problem (missing interpreter, missing package, missing a transitive
/// dependency like Pillow/mujoco/redis) from a transient subprocess crash that's worth retrying
/// with backoff.
///
/// Deliberately imports `mecha10_rl.mujoco.cli` rather than the bare `mecha10_rl` package -- the
/// package's own `__init__.py` wraps its heavier imports in a `try/except ImportError: pass`
/// (so lightweight, non-mujoco consumers of `mecha10_rl` don't need the full mujoco/torch/Pillow
/// stack installed), so a bare `import mecha10_rl` silently succeeds even when a dependency
/// `cli.py` genuinely needs is missing.
///
/// Returns `Ok(())` when `mecha10_rl.mujoco.cli` imports cleanly. Returns `Err` with an
/// actionable message (including the interpreter's own stderr, when available) otherwise.
///
/// `mecha10_rl` lives in the standalone `mecha-industries/mecha10-rl` repo, at
/// `rl-trainer/pyproject.toml` there. The fix-it message below points at installing it via `pip
/// install 'mecha10-rl @ git+ssh://git@github.com/mecha-industries/mecha10-rl.git@main#subdirectory=rl-trainer'`,
/// the same pattern `packages/mecha10-templates/templates/rl/requirements-rl.txt.template`
/// already scaffolds into end-user robot projects outside this monorepo.
pub async fn check_mecha10_rl_importable(python_executable: &str) -> Result<(), String> {
    let output = Command::new(python_executable)
        .args(["-c", "import mecha10_rl.mujoco.cli"])
        .output()
        .await;

    match output {
        Ok(output) if output.status.success() => Ok(()),
        Ok(output) => {
            let stderr = String::from_utf8_lossy(&output.stderr);
            let detail = stderr.lines().last().unwrap_or("").trim();
            Err(format!(
                "'{python_executable} -c \"import mecha10_rl.mujoco.cli\"' failed ({}){}. \
                 `mecha10_rl` and/or one of its dependencies (mujoco, Pillow, redis, gymnasium) is \
                 not installed for this interpreter - run `pip install \
                 'mecha10-rl @ git+ssh://git@github.com/mecha-industries/mecha10-rl.git@main#subdirectory=rl-trainer'` \
                 (see packages/mecha10-templates/templates/rl/requirements-rl.txt.template for \
                 the same pattern used in scaffolded user projects), inside this resolved \
                 interpreter's environment - or create a project venv with `mecha10 setup` (which \
                 mecha10 then auto-detects and prefers) and re-run `mecha10 dev`.",
                output.status,
                if detail.is_empty() {
                    String::new()
                } else {
                    format!(": {detail}")
                }
            ))
        }
        Err(e) => Err(format!(
            "failed to run '{python_executable}': {e}. Is \"{python_executable}\" (the auto-resolved \
             interpreter) a valid interpreter on PATH? Install Python 3, or create a project venv \
             with `mecha10 setup`, which mecha10 auto-detects and prefers over the bare system \
             interpreter."
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    fn unique_temp_dir(label: &str) -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "cli-sim-python-env-test-{label}-{:?}",
            std::thread::current().id()
        ));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn leaves_explicit_override_untouched_even_if_venv_exists() {
        let root = unique_temp_dir("explicit-override");
        let bin_dir = if cfg!(windows) {
            root.join(".venv").join("Scripts")
        } else {
            root.join(".venv").join("bin")
        };
        fs::create_dir_all(&bin_dir).unwrap();
        let venv_python = bin_dir.join(if cfg!(windows) { "python.exe" } else { "python" });
        fs::write(&venv_python, b"").unwrap();

        let resolved = resolve_python_executable("/usr/bin/python3.11", &root);
        assert_eq!(resolved, "/usr/bin/python3.11");

        fs::remove_dir_all(&root).unwrap();
    }

    #[test]
    fn falls_back_to_configured_default_when_no_venv_exists() {
        let root = unique_temp_dir("no-venv");
        let resolved = resolve_python_executable(DEFAULT_PYTHON_EXECUTABLE, &root);
        assert_eq!(resolved, DEFAULT_PYTHON_EXECUTABLE);
        fs::remove_dir_all(&root).unwrap();
    }

    #[test]
    fn prefers_project_venv_when_default_and_venv_exists() {
        let root = unique_temp_dir("has-venv");
        let bin_dir = if cfg!(windows) {
            root.join(".venv").join("Scripts")
        } else {
            root.join(".venv").join("bin")
        };
        fs::create_dir_all(&bin_dir).unwrap();
        let venv_python = bin_dir.join(if cfg!(windows) { "python.exe" } else { "python" });
        fs::write(&venv_python, b"").unwrap();

        let resolved = resolve_python_executable(DEFAULT_PYTHON_EXECUTABLE, &root);
        assert_eq!(resolved, venv_python.display().to_string());

        fs::remove_dir_all(&root).unwrap();
    }

    #[tokio::test]
    async fn check_mecha10_rl_importable_reports_actionable_error_for_missing_interpreter() {
        let err = check_mecha10_rl_importable("definitely-not-a-real-interpreter-xyz")
            .await
            .expect_err("nonexistent interpreter should fail");
        assert!(err.contains("definitely-not-a-real-interpreter-xyz"));
        assert!(err.contains("mecha10 setup"));
    }
}