diff --git a/crates/uv-python/src/installation.rs b/crates/uv-python/src/installation.rs index 171cd73f59e..763d1855c0c 100644 --- a/crates/uv-python/src/installation.rs +++ b/crates/uv-python/src/installation.rs @@ -327,7 +327,8 @@ impl PythonInstallation { let installations = ManagedPythonInstallations::from_settings(None)?.init()?; let installations_dir = installations.root(); let scratch_dir = installations.scratch(); - let _lock = installations.lock().await?; + let lock = installations.lock().await?; + installations.clear_scratch(&lock)?; info!("Fetching requested Python..."); let result = download diff --git a/crates/uv-python/src/managed.rs b/crates/uv-python/src/managed.rs index 38d2a9432d8..6f9b7c42d0c 100644 --- a/crates/uv-python/src/managed.rs +++ b/crates/uv-python/src/managed.rs @@ -15,6 +15,7 @@ use tracing::{debug, warn}; #[cfg(windows)] use windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; +use uv_cache::{Removal, rm_rf}; use uv_fs::{ LockedFile, LockedFileError, LockedFileMode, Simplified, normalize_absolute_path, replace_symlink, symlink_or_copy_file, verbatim_path, @@ -151,6 +152,21 @@ impl ManagedPythonInstallations { self.root.join(".temp") } + /// Remove the contents of the scratch directory for managed Python installations. + /// + /// The caller must hold the managed Python installation lock to avoid removing an active + /// download from another process. Preserve the scratch directory itself, since another + /// process may have initialized it before waiting for the same lock. + pub fn clear_scratch(&self, _lock: &LockedFile) -> Result { + let mut removal = Removal::default(); + + for entry in fs::read_dir(self.scratch())? { + removal += rm_rf(entry?.path())?; + } + + Ok(removal) + } + /// Initialize the Python installation directory. /// /// Ensures the directory is created. diff --git a/crates/uv/src/commands/cache_clean.rs b/crates/uv/src/commands/cache_clean.rs index 583b1fe698f..b66dc4c63aa 100644 --- a/crates/uv/src/commands/cache_clean.rs +++ b/crates/uv/src/commands/cache_clean.rs @@ -7,6 +7,7 @@ use tracing::debug; use uv_cache::{Cache, Removal}; use uv_fs::Simplified; use uv_normalize::PackageName; +use uv_python::managed::ManagedPythonInstallations; use crate::commands::reporters::{CleaningDirectoryReporter, CleaningPackageReporter}; use crate::commands::{ExitStatus, human_readable_bytes}; @@ -19,12 +20,44 @@ pub(crate) async fn cache_clean( cache: Cache, printer: Printer, ) -> Result { + let mut python_removal = Removal::default(); + let mut python_scratch = None; + + if packages.is_empty() { + let installations = ManagedPythonInstallations::from_settings(None)?; + let scratch = installations.scratch(); + + if scratch.is_dir() { + let lock = installations.lock().await?; + python_removal = installations.clear_scratch(&lock).with_context(|| { + format!( + "Failed to clear temporary Python downloads at: {}", + scratch.user_display() + ) + })?; + + if python_removal.num_files > 0 || python_removal.num_dirs > 0 { + python_scratch = Some(scratch); + } + } + } + if !cache.root().exists() { writeln!( printer.stderr(), "No cache found at: {}", cache.root().user_display().cyan() )?; + + if let Some(scratch) = python_scratch { + writeln!( + printer.stderr(), + "Clearing temporary Python downloads at: {}", + scratch.user_display().cyan() + )?; + write_removal_summary(&python_removal, printer)?; + } + return Ok(ExitStatus::Success); } @@ -43,13 +76,21 @@ pub(crate) async fn cache_clean( } }; - let summary = if packages.is_empty() { + let mut summary = if packages.is_empty() { writeln!( printer.stderr(), "Clearing cache at: {}", cache.root().user_display().cyan() )?; + if let Some(scratch) = python_scratch { + writeln!( + printer.stderr(), + "Clearing temporary Python downloads at: {}", + scratch.user_display().cyan() + )?; + } + let num_paths = walkdir::WalkDir::new(cache.root()).into_iter().count(); let reporter = CleaningDirectoryReporter::new(printer, Some(num_paths)); @@ -71,6 +112,15 @@ pub(crate) async fn cache_clean( summary }; + summary += python_removal; + + write_removal_summary(&summary, printer)?; + + Ok(ExitStatus::Success) +} + +/// Write a summary of the files, directories, and bytes removed. +fn write_removal_summary(summary: &Removal, printer: Printer) -> Result<()> { // Write a summary of the number of files and directories removed. match (summary.num_files, summary.num_dirs) { (0, 0) => { @@ -103,5 +153,5 @@ pub(crate) async fn cache_clean( writeln!(printer.stderr())?; - Ok(ExitStatus::Success) + Ok(()) } diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 793512485d0..624d6fa5256 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -328,7 +328,8 @@ async fn perform_install( let installations = ManagedPythonInstallations::from_settings(install_dir.clone())?.init()?; let installations_dir = installations.root(); let scratch_dir = installations.scratch(); - let _lock = installations.lock().await?; + let lock = installations.lock().await?; + installations.clear_scratch(&lock)?; let existing_installations: Vec<_> = installations .find_all()? .inspect(|installation| trace!("Found existing installation {}", installation.key())) diff --git a/crates/uv/tests/build/cache_clean.rs b/crates/uv/tests/build/cache_clean.rs index 9d68c84afde..b57e22dc1ef 100644 --- a/crates/uv/tests/build/cache_clean.rs +++ b/crates/uv/tests/build/cache_clean.rs @@ -3,6 +3,7 @@ use assert_cmd::prelude::*; use assert_fs::prelude::*; use uv_cache::Cache; +use uv_python::managed::ManagedPythonInstallations; use uv_static::EnvVars; use uv_test::uv_snapshot; @@ -64,6 +65,148 @@ fn clear_all_alias() -> Result<()> { Ok(()) } +/// A full cache clean should also reclaim interrupted managed Python downloads. +#[test] +fn clean_all_python_temporary_directories() -> Result<()> { + let context = uv_test::test_context!("3.12") + .with_filtered_counts() + .with_managed_python_dirs(); + + let managed = context.temp_dir.child("managed"); + let scratch = managed.child(".temp"); + let interrupted = scratch.child(".tmp-interrupted"); + interrupted.create_dir_all()?; + interrupted.child("download").write_str("partial Python")?; + + let installation = managed.child("cpython-existing"); + installation.create_dir_all()?; + installation.child("python").write_str("installed Python")?; + + uv_snapshot!(context.filters(), context.clean(), @" + exit_code: 0 (success) + ----- stderr ----- + Clearing cache at: [CACHE_DIR]/ + Clearing temporary Python downloads at: managed/.temp + Removed [N] files ([SIZE]) + "); + + assert!(scratch.is_dir()); + assert!(!interrupted.exists()); + assert!(installation.child("python").is_file()); + + Ok(()) +} + +/// An empty managed Python scratch directory should not produce a cleanup message. +#[test] +fn clean_all_does_not_report_empty_python_temporary_directories() -> Result<()> { + let context = uv_test::test_context!("3.12") + .with_filtered_counts() + .with_managed_python_dirs(); + + let scratch = context.temp_dir.child("managed").child(".temp"); + scratch.create_dir_all()?; + + context.cache_dir.create_dir_all()?; + context.cache_dir.child("cached").write_str("cached")?; + + uv_snapshot!(context.filters(), context.clean(), @" + exit_code: 0 (success) + ----- stderr ----- + Clearing cache at: [CACHE_DIR]/ + Removed [N] files ([SIZE]) + "); + + assert!(scratch.is_dir()); + + Ok(()) +} + +/// Managed Python downloads can still be cleaned when the package cache is absent. +#[test] +fn clean_python_temporary_directories_without_cache() -> Result<()> { + let context = uv_test::test_context!("3.12").with_managed_python_dirs(); + + if context.cache_dir.exists() { + fs_err::remove_dir_all(&context.cache_dir)?; + } + + let scratch = context.temp_dir.child("managed").child(".temp"); + let interrupted = scratch.child(".tmp-interrupted"); + interrupted.create_dir_all()?; + interrupted.child("download").write_str("partial Python")?; + + uv_snapshot!(context.filters(), context.clean(), @" + exit_code: 0 (success) + ----- stderr ----- + No cache found at: [CACHE_DIR]/ + Clearing temporary Python downloads at: managed/.temp + Removed 1 file ([SIZE]) + "); + + assert!(scratch.is_dir()); + assert!(!interrupted.exists()); + + Ok(()) +} + +/// Cleaning an individual package must not remove managed Python downloads. +#[test] +fn clean_package_preserves_python_temporary_directories() -> Result<()> { + let context = uv_test::test_context!("3.12").with_managed_python_dirs(); + + let scratch = context.temp_dir.child("managed").child(".temp"); + let interrupted = scratch.child(".tmp-interrupted"); + interrupted.create_dir_all()?; + interrupted.child("download").write_str("partial Python")?; + + uv_snapshot!(context.filters(), context.clean().arg("missing-package"), @" + exit_code: 0 (success) + ----- stderr ----- + No cache entries found + "); + + assert!(interrupted.child("download").is_file()); + + Ok(()) +} + +/// A cache clean, even with `--force`, must not remove an active managed Python download. +#[tokio::test] +async fn clean_python_temporary_directories_waits_for_installation_lock() -> Result<()> { + let context = uv_test::test_context!("3.12").with_managed_python_dirs(); + + let managed = context.temp_dir.child("managed"); + let scratch = managed.child(".temp"); + let active = scratch.child(".tmp-active"); + active.create_dir_all()?; + active + .child("download") + .write_str("active Python download")?; + + let installations = + ManagedPythonInstallations::from_settings(Some(managed.to_path_buf()))?.init()?; + let _lock = installations.lock().await?; + + uv_snapshot!(context.filters(), context.clean().env(EnvVars::UV_LOCK_TIMEOUT, "1"), @" + exit_code: 2 (failure) + ----- stderr ----- + error: Timeout ([TIME]) when waiting for lock on `managed` at `managed/.lock`, is another uv process running? You can set `UV_LOCK_TIMEOUT` to increase the timeout. + "); + + assert!(active.child("download").is_file()); + + uv_snapshot!(context.filters(), context.clean().arg("--force").env(EnvVars::UV_LOCK_TIMEOUT, "1"), @" + exit_code: 2 (failure) + ----- stderr ----- + error: Timeout ([TIME]) when waiting for lock on `managed` at `managed/.lock`, is another uv process running? You can set `UV_LOCK_TIMEOUT` to increase the timeout. + "); + + assert!(active.child("download").is_file()); + + Ok(()) +} + #[tokio::test] async fn clean_force() -> Result<()> { let context = uv_test::test_context!("3.12").with_filtered_counts(); diff --git a/crates/uv/tests/python/python_install.rs b/crates/uv/tests/python/python_install.rs index a65e383d74f..67bb25e8397 100644 --- a/crates/uv/tests/python/python_install.rs +++ b/crates/uv/tests/python/python_install.rs @@ -124,6 +124,58 @@ fn python_install() { bin_python.assert(predicate::path::missing()); } +/// An explicit Python install should reclaim downloads left behind by an interrupted process. +#[test] +fn python_install_cleans_stale_temporary_directories() -> anyhow::Result<()> { + let context = uv_test::test_context_with_versions!(&[]).with_managed_python_dirs(); + + let scratch = context.temp_dir.child("managed").child(".temp"); + let interrupted = scratch.child(".tmp-interrupted"); + interrupted.create_dir_all()?; + interrupted.child("download").write_str("partial Python")?; + + uv_snapshot!(context.filters(), context.python_install().arg("foobar"), @" + exit_code: 2 (failure) + ----- stderr ----- + error: `foobar` is not a valid Python download request; see `uv help python` for supported formats and `uv python list --only-downloads` for available versions + "); + + assert!(scratch.is_dir()); + assert!(!interrupted.exists()); + + Ok(()) +} + +/// Automatically downloading Python should also reclaim interrupted managed downloads. +#[test] +fn python_install_automatic_cleans_stale_temporary_directories() -> anyhow::Result<()> { + let context = uv_test::test_context_with_versions!(&[]) + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_managed_python_dirs() + .with_python_download_cache(); + + let scratch = context.temp_dir.child("managed").child(".temp"); + let interrupted = scratch.child(".tmp-interrupted"); + interrupted.create_dir_all()?; + interrupted.child("download").write_str("partial Python")?; + + uv_snapshot!(context.filters(), context.run() + .env_remove(EnvVars::VIRTUAL_ENV) + .arg("python") + .arg("-c") + .arg("import sys; print(sys.version_info[:2])"), @" + exit_code: 0 (success) + ----- stdout ----- + (3, 14) + "); + + assert!(scratch.is_dir()); + assert!(!interrupted.exists()); + + Ok(()) +} + #[test] fn python_reinstall() { let context = uv_test::test_context_with_versions!(&[])