diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 1a0274cac..2b1af8ebc 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -920,7 +920,9 @@ impl ProjectInterpreter { )); } InvalidEnvironmentKind::MissingExecutable(_) => { - if fs_err::read_dir(&root).is_ok_and(|mut dir| dir.next().is_some()) { + if fs_err::read_dir(&root).is_ok_and(|mut dir| dir.next().is_some()) + && !root.join(".uv-partial-rm").try_exists().unwrap_or(false) + { return Err(ProjectError::InvalidProjectEnvironmentDir( root, "it is not a valid Python environment (no Python executable was found)" @@ -1294,6 +1296,9 @@ impl ProjectEnvironment { // Unless it's empty, in which case we just ignore it if root.read_dir().is_ok_and(|mut dir| dir.next().is_none()) { false + // Or, if there's a marker file indicating a previous removal failed + } else if root.join(".uv-partial-rm").try_exists().unwrap_or(false) { + true } else { return Err(ProjectError::InvalidProjectEnvironmentDir( root, @@ -1363,7 +1368,23 @@ impl ProjectEnvironment { )?; } Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(e.into()), + Err(e) => { + // Try to write a marker to indicate that we failed to remove the entire + // environment, so when the next command runs we can attempt to remove + // it even if a `pyvenv.cfg` marker was removed + match fs_err::write(root.join(".uv-partial-rm"), "") { + Ok(()) => { + debug!( + "Wrote `.uv-partial-rm` marker to partially deleted environment: {}", + root.user_display().cyan() + ); + } + Err(err) => { + debug!("Failed to write `.uv-partial-rm` marker: {err}"); + } + } + return Err(e.into()); + } } } diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index d4479296a..d6cc7f26a 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -5000,6 +5000,59 @@ fn sync_update_project() -> Result<()> { Ok(()) } +#[test] +fn sync_no_pyvenv_cfg() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "my-project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#, + )?; + + // Break the virtual environment + fs_err::remove_dir_all( + context + .venv + .join(if cfg!(windows) { "Scripts" } else { "bin" }), + )?; + fs_err::remove_file(context.venv.join("pyvenv.cfg"))?; + + // Running `uv sync` won't create the venv + uv_snapshot!(context.filters(), context.sync(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Project virtual environment directory `[VENV]/` cannot be used because it is not a valid Python environment (no Python executable was found) + "); + + // Write the marker that indicates the environment was partially removed by uv + fs_err::write(context.venv.join(".uv-partial-rm"), "")?; + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Removed virtual environment at: .venv + Creating virtual environment at: .venv + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "); + + Ok(()) +} + #[test] fn sync_environment_prompt() -> Result<()> { let context = TestContext::new_with_versions(&["3.12"]);