py-check-updates 0.0.4

Check and update Python project dependencies
Documentation
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread::sleep;
use std::time::Duration;

use anyhow::{Context, Result, bail};

use crate::changes::safe_text;
use crate::workspace::read_project;

fn parent_workspace(directory: &Path) -> Result<Option<PathBuf>> {
    for candidate in directory.ancestors() {
        let path = candidate.join("pyproject.toml");
        if !path.is_file() {
            continue;
        }
        let project = read_project(&path)?;
        if project
            .document
            .get("tool")
            .and_then(|v| v.get("uv"))
            .and_then(|v| v.get("workspace"))
            .is_some()
        {
            return Ok((candidate != directory).then(|| candidate.to_owned()));
        }
    }
    Ok(None)
}

pub fn lock_project(directory: &Path) -> Result<()> {
    let directory = directory.canonicalize()?;
    let recovery = format!(
        "Requirement files were updated. uv.lock may be stale or changed. Resolve the error and run uv lock in {}, or review and restore the affected files.",
        safe_text(&directory.to_string_lossy(), false)
    );
    lock_inner(&directory).with_context(|| recovery)
}

fn lock_inner(directory: &Path) -> Result<()> {
    match fs::metadata(directory.join("uv.lock")) {
        Ok(metadata) if metadata.is_file() => (),
        Ok(_) => return Ok(()),
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
        Err(error) => return Err(error.into()),
    }
    if let Some(parent) = parent_workspace(directory)? {
        eprintln!(
            "Skipping uv lock: parent workspace found at {}. Requirement files were updated; lockfiles were not changed. Use -w to update and lock the workspace, or review and lock manually.",
            safe_text(&parent.to_string_lossy(), false)
        );
        return Ok(());
    }
    println!(
        "Running uv lock in {}...",
        safe_text(&directory.to_string_lossy(), false)
    );
    let mut child = Command::new("uv")
        .arg("lock")
        .current_dir(directory)
        .env_remove("UV_PROJECT")
        .env_remove("UV_FROZEN")
        .stdin(Stdio::null())
        .spawn()
        .context("uv lock could not start")?;
    loop {
        if let Err(error) = crate::check_interrupt() {
            let _ = child.kill();
            let _ = child.wait();
            return Err(
                error.context("Interrupted during uv lock; lock completion is not confirmed")
            );
        }
        if let Some(status) = child.try_wait()? {
            if !status.success() {
                bail!("uv lock failed ({status})");
            }
            println!("uv lock completed.");
            return Ok(());
        }
        sleep(Duration::from_millis(50));
    }
}

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

    #[test]
    fn parent_workspace_guard_stops_at_local_workspace() {
        let temp = tempfile::tempdir().unwrap();
        fs::write(
            temp.path().join("pyproject.toml"),
            "[tool.uv.workspace]\nmembers=[]",
        )
        .unwrap();
        let member = temp.path().join("member");
        fs::create_dir(&member).unwrap();
        fs::write(member.join("pyproject.toml"), "[project]\ndependencies=[]").unwrap();
        assert_eq!(
            parent_workspace(&member).unwrap(),
            Some(temp.path().to_owned())
        );
        fs::write(
            member.join("pyproject.toml"),
            "[tool.uv.workspace]\nmembers=[]",
        )
        .unwrap();
        assert!(parent_workspace(&member).unwrap().is_none());
    }

    #[test]
    fn missing_or_directory_lockfile_does_not_start_uv() {
        let temp = tempfile::tempdir().unwrap();
        lock_project(temp.path()).unwrap();
        fs::create_dir(temp.path().join("uv.lock")).unwrap();
        lock_project(temp.path()).unwrap();
    }
}