Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/uv-python/src/installation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions crates/uv-python/src/managed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Removal, Error> {
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.
Expand Down
54 changes: 52 additions & 2 deletions crates/uv/src/commands/cache_clean.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand All @@ -19,12 +20,44 @@ pub(crate) async fn cache_clean(
cache: Cache,
printer: Printer,
) -> Result<ExitStatus> {
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);
}

Expand All @@ -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));

Expand All @@ -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) => {
Expand Down Expand Up @@ -103,5 +153,5 @@ pub(crate) async fn cache_clean(

writeln!(printer.stderr())?;

Ok(ExitStatus::Success)
Ok(())
}
3 changes: 2 additions & 1 deletion crates/uv/src/commands/python/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
Expand Down
143 changes: 143 additions & 0 deletions crates/uv/tests/build/cache_clean.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
52 changes: 52 additions & 0 deletions crates/uv/tests/python/python_install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(&[])
Expand Down
Loading