maelstrom-pytest 0.14.0

Python Test Runner for Maelstrom.
use crate::{BuildDir, PytestCaseMetadata, PytestConfig, PytestPackageId, PytestTestArtifact};
use anyhow::{anyhow, bail, Result};
use maelstrom_client::{
    spec::{ImageRef, LayerSpec},
    ProjectDir,
};
use maelstrom_test_runner::{util::UseColor, WaitStatus};
use maelstrom_util::{process::ExitCode, root::Root};
use serde::Deserialize;
use std::{
    collections::HashMap,
    io::Read as _,
    os::unix::process::ExitStatusExt as _,
    path::Path,
    process::{Command, Stdio},
    sync::Mutex,
    thread,
};

pub struct WaitHandle(Mutex<Option<WaitStatus>>);

impl WaitHandle {
    fn new(wait_status: WaitStatus) -> Self {
        Self(Mutex::new(Some(wait_status)))
    }

    fn success() -> Self {
        Self::new(WaitStatus {
            exit_code: ExitCode::SUCCESS,
            output: "".into(),
        })
    }

    pub fn wait(&self) -> Result<WaitStatus> {
        Ok(self.0.lock().unwrap().take().unwrap())
    }

    pub fn kill(&self) -> Result<()> {
        Ok(())
    }
}

pub struct TestArtifactStream(std::collections::hash_map::IntoValues<String, PytestTestArtifact>);

impl Iterator for TestArtifactStream {
    type Item = Result<PytestTestArtifact>;

    fn next(&mut self) -> Option<Result<PytestTestArtifact>> {
        self.0.next().map(Ok)
    }
}

fn compile_python(path: &Path) -> Result<()> {
    let mut cmd = Command::new("/usr/bin/env");
    cmd.args(["python", "-m", "compileall"])
        .arg(path)
        .stderr(Stdio::null())
        .stdout(Stdio::null());
    let mut child = cmd.spawn()?;

    let exit_status = child.wait()?;
    if exit_status.success() {
        Ok(())
    } else {
        bail!("failed to compile python")
    }
}

fn run_python(script: &str, cwd: &Path, args: Vec<String>) -> Result<WaitStatus> {
    let mut cmd = Command::new("/usr/bin/env");
    cmd.args(["python", "-c", script])
        .args(args)
        .current_dir(cwd)
        .stderr(Stdio::piped())
        .stdout(Stdio::piped());
    let mut child = cmd.spawn()?;

    let mut stdout = child.stdout.take().unwrap();
    let stdout_handle = thread::spawn(move || -> Result<String> {
        let mut stdout_string = String::new();
        stdout.read_to_string(&mut stdout_string)?;
        Ok(stdout_string)
    });

    let mut stderr = child.stderr.take().unwrap();
    let stderr_handle = thread::spawn(move || -> Result<String> {
        let mut stderr_string = String::new();
        stderr.read_to_string(&mut stderr_string)?;
        Ok(stderr_string)
    });

    let stdout = stdout_handle.join().unwrap()?;
    let stderr = stderr_handle.join().unwrap()?;

    let exit_status = child.wait()?;
    if exit_status.success() {
        Ok(WaitStatus {
            exit_code: ExitCode::SUCCESS,
            output: stdout,
        })
    } else {
        let exit_code = exit_status
            .code()
            .unwrap_or_else(|| 128 + exit_status.signal().unwrap());
        Ok(WaitStatus {
            output: stderr,
            exit_code: ExitCode::from(exit_code as u8),
        })
    }
}

#[derive(Deserialize)]
struct PytestCase {
    file: String,
    name: String,
    node_id: String,
    markers: Vec<String>,
    skip: bool,
}

pub fn pytest_collect_tests(
    _use_color: UseColor,
    pytest_options: &PytestConfig,
    project_dir: &Root<ProjectDir>,
    build_dir: &Root<BuildDir>,
    test_layers: HashMap<ImageRef, LayerSpec>,
) -> Result<(WaitHandle, TestArtifactStream)> {
    compile_python(project_dir.as_ref())?;

    let mut args = vec![
        "--ignore".into(),
        build_dir
            .to_str()
            .ok_or_else(|| anyhow!("non UTF-8 path"))?
            .to_owned(),
    ];
    if let Some(arg) = &pytest_options.collect_from_module {
        args.push("--pyargs".into());
        args.push(arg.into());
    }
    args.extend(pytest_options.extra_pytest_args.clone());
    args.extend(pytest_options.extra_pytest_collect_args.clone());
    let wait_status = run_python(include_str!("py/collect_tests.py"), project_dir, args)?;
    if wait_status.exit_code != ExitCode::SUCCESS {
        return Ok((
            WaitHandle::new(wait_status),
            TestArtifactStream(HashMap::new().into_values()),
        ));
    }
    let output = wait_status.output;
    let mut tests = HashMap::new();
    for line in output.split('\n').filter(|l| !l.is_empty()) {
        let case: PytestCase = serde_json::from_str(line)?;
        let path = Path::new(&case.file)
            .strip_prefix(project_dir)
            .unwrap_or_else(|err| {
                panic!(
                    "error stripping prefix {project_dir:?} from {file}: {err}",
                    file = case.file
                )
            });
        let path_str = path.to_str().unwrap().to_owned();
        let test = tests.entry(path_str.clone()).or_insert(PytestTestArtifact {
            path: path.to_path_buf(),
            tests: vec![],
            ignored_tests: vec![],
            package: PytestPackageId("default".into()),
            pytest_options: pytest_options.clone(),
            test_layers: test_layers.clone(),
        });
        if case.skip {
            test.ignored_tests.push(case.name.clone());
        }
        test.tests.push((
            case.name,
            PytestCaseMetadata {
                node_id: case.node_id,
                markers: case.markers,
            },
        ));
    }

    Ok((
        WaitHandle::success(),
        TestArtifactStream(tests.into_values()),
    ))
}