Require --force to replace existing virtual environments in uv venv

This commit is contained in:
Zanie Blue 2025-04-15 16:52:15 -05:00
parent df35919d5a
commit 9e69276a6f
11 changed files with 101 additions and 19 deletions

View file

@ -323,6 +323,7 @@ impl SourceBuild {
uv_virtualenv::Prompt::None, uv_virtualenv::Prompt::None,
false, false,
false, false,
uv_virtualenv::VenvForceMode::ReplaceAny,
false, false,
false, false,
)? )?

View file

@ -2470,16 +2470,26 @@ pub struct VenvArgs {
/// Preserve any existing files or directories at the target path. /// Preserve any existing files or directories at the target path.
/// ///
/// By default, `uv venv` will remove an existing virtual environment at the given path, and /// By default, `uv venv` will exit with an error if the directory is non-empty but _not_ a
/// exit with an error if the path is non-empty but _not_ a virtual environment. The /// virtual environment. The `--allow-existing` option will instead write to the given path,
/// `--allow-existing` option will instead write to the given path, regardless of its contents, /// regardless of its contents, and without clearing it beforehand.
/// and without clearing it beforehand.
/// ///
/// WARNING: This option can lead to unexpected behavior if the existing virtual environment and /// WARNING: This option can lead to unexpected behavior if the existing virtual environment and
/// the newly-created virtual environment are linked to different Python interpreters. /// the newly-created virtual environment are linked to different Python interpreters.
#[clap(long)] #[clap(long)]
pub allow_existing: bool, pub allow_existing: bool,
/// Replace an existing directory at the target path.
///
/// By default, `uv venv` will not replace an existing directory. Use `-f` to replace an
/// existing virtual environment, or `-ff` to replace a directory that is not a virtual
/// environment.
///
/// See `--allow-existing` to create a virtual environment in a directory, regardless of its
/// contents, and without clearing it beforehand.
#[clap(long, action = clap::ArgAction::Count)]
pub force: u8,
/// The path to the virtual environment to create. /// The path to the virtual environment to create.
/// ///
/// Default to `.venv` in the working directory. /// Default to `.venv` in the working directory.

View file

@ -281,6 +281,7 @@ impl InstalledTools {
uv_virtualenv::Prompt::None, uv_virtualenv::Prompt::None,
false, false,
false, false,
uv_virtualenv::VenvForceMode::ReplaceAny,
false, false,
false, false,
)?; )?;

View file

@ -38,6 +38,29 @@ impl Prompt {
} }
} }
// TODO(zanieb): Consider folding `allow_existing` into this?
#[derive(Debug, Clone, Copy)]
pub enum VenvForceMode {
/// Do not replace an existing directory.
Disabled,
/// Replace an existing directory, if it is a virtual environment.
ReplaceEnvironment,
/// Replace an existing directory, regardless of contents.
ReplaceAny,
}
impl VenvForceMode {
pub fn from_args(force: u8) -> Self {
if force == 0 {
VenvForceMode::Disabled
} else if force == 1 {
VenvForceMode::ReplaceEnvironment
} else {
VenvForceMode::ReplaceAny
}
}
}
/// Create a virtualenv. /// Create a virtualenv.
#[allow(clippy::fn_params_excessive_bools)] #[allow(clippy::fn_params_excessive_bools)]
pub fn create_venv( pub fn create_venv(
@ -46,6 +69,7 @@ pub fn create_venv(
prompt: Prompt, prompt: Prompt,
system_site_packages: bool, system_site_packages: bool,
allow_existing: bool, allow_existing: bool,
force: VenvForceMode,
relocatable: bool, relocatable: bool,
seed: bool, seed: bool,
) -> Result<PythonEnvironment, Error> { ) -> Result<PythonEnvironment, Error> {
@ -56,6 +80,7 @@ pub fn create_venv(
prompt, prompt,
system_site_packages, system_site_packages,
allow_existing, allow_existing,
force,
relocatable, relocatable,
seed, seed,
)?; )?;

View file

@ -16,7 +16,7 @@ use uv_python::{Interpreter, VirtualEnvironment};
use uv_shell::escape_posix_for_single_quotes; use uv_shell::escape_posix_for_single_quotes;
use uv_version::version; use uv_version::version;
use crate::{Error, Prompt}; use crate::{Error, Prompt, VenvForceMode};
/// Activation scripts for the environment, with dependent paths templated out. /// Activation scripts for the environment, with dependent paths templated out.
const ACTIVATE_TEMPLATES: &[(&str, &str)] = &[ const ACTIVATE_TEMPLATES: &[(&str, &str)] = &[
@ -51,6 +51,7 @@ pub(crate) fn create(
prompt: Prompt, prompt: Prompt,
system_site_packages: bool, system_site_packages: bool,
allow_existing: bool, allow_existing: bool,
force: VenvForceMode,
relocatable: bool, relocatable: bool,
seed: bool, seed: bool,
) -> Result<VirtualEnvironment, Error> { ) -> Result<VirtualEnvironment, Error> {
@ -79,11 +80,35 @@ pub(crate) fn create(
format!("File exists at `{}`", location.user_display()), format!("File exists at `{}`", location.user_display()),
))); )));
} else if metadata.is_dir() { } else if metadata.is_dir() {
if allow_existing { let is_virtualenv = location.join("pyvenv.cfg").is_file();
debug!("Allowing existing directory"); let is_empty = !is_virtualenv
} else if location.join("pyvenv.cfg").is_file() { && location
debug!("Removing existing directory"); .read_dir()
.is_ok_and(|mut dir| dir.next().is_none());
let should_remove = if !is_empty {
if allow_existing {
debug!("Allowing existing directory due to `--allow-existing`");
false
} else {
match force {
VenvForceMode::Disabled => false,
VenvForceMode::ReplaceEnvironment => {
if is_virtualenv {
debug!("Replacing existing virtual environment due to `-f`");
}
is_virtualenv
}
VenvForceMode::ReplaceAny => {
debug!("Replacing existing directory due to `-ff`");
true
}
}
}
} else {
false
};
if should_remove {
// On Windows, if the current executable is in the directory, guard against // On Windows, if the current executable is in the directory, guard against
// self-deletion. // self-deletion.
#[cfg(windows)] #[cfg(windows)]
@ -97,18 +122,20 @@ pub(crate) fn create(
fs::remove_dir_all(location)?; fs::remove_dir_all(location)?;
fs::create_dir_all(location)?; fs::create_dir_all(location)?;
} else if location } else if !is_empty && !allow_existing {
.read_dir()
.is_ok_and(|mut dir| dir.next().is_none())
{
debug!("Ignoring empty directory");
} else {
return Err(Error::Io(io::Error::new( return Err(Error::Io(io::Error::new(
io::ErrorKind::AlreadyExists, io::ErrorKind::AlreadyExists,
format!( if is_virtualenv {
"The directory `{}` exists, but it's not a virtual environment", format!(
location.user_display() "The virtual environment `{}` already exists, use `-f` to replace it",
), location.user_display()
)
} else {
format!(
"The directory `{}` already exists, use `-ff` to replace it",
location.user_display()
)
},
))); )));
} }
} }

View file

@ -92,6 +92,7 @@ impl CachedEnvironment {
uv_virtualenv::Prompt::None, uv_virtualenv::Prompt::None,
false, false,
false, false,
uv_virtualenv::VenvForceMode::ReplaceAny,
true, true,
false, false,
)?; )?;

View file

@ -1239,6 +1239,7 @@ impl ProjectEnvironment {
prompt, prompt,
false, false,
false, false,
uv_virtualenv::VenvForceMode::ReplaceAny,
false, false,
false, false,
)?; )?;
@ -1276,6 +1277,7 @@ impl ProjectEnvironment {
prompt, prompt,
false, false,
false, false,
uv_virtualenv::VenvForceMode::ReplaceAny,
false, false,
false, false,
)?; )?;
@ -1405,6 +1407,7 @@ impl ScriptEnvironment {
prompt, prompt,
false, false,
false, false,
uv_virtualenv::VenvForceMode::ReplaceAny,
false, false,
false, false,
)?; )?;
@ -1439,6 +1442,7 @@ impl ScriptEnvironment {
prompt, prompt,
false, false,
false, false,
uv_virtualenv::VenvForceMode::ReplaceAny,
false, false,
false, false,
)?; )?;

View file

@ -432,6 +432,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
uv_virtualenv::Prompt::None, uv_virtualenv::Prompt::None,
false, false,
false, false,
uv_virtualenv::VenvForceMode::ReplaceAny,
false, false,
false, false,
)?; )?;
@ -629,6 +630,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
uv_virtualenv::Prompt::None, uv_virtualenv::Prompt::None,
false, false,
false, false,
uv_virtualenv::VenvForceMode::ReplaceAny,
false, false,
false, false,
)? )?
@ -854,6 +856,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
uv_virtualenv::Prompt::None, uv_virtualenv::Prompt::None,
false, false,
false, false,
uv_virtualenv::VenvForceMode::ReplaceAny,
false, false,
false, false,
)?; )?;

View file

@ -29,6 +29,7 @@ use uv_resolver::{ExcludeNewer, FlatIndex};
use uv_settings::PythonInstallMirrors; use uv_settings::PythonInstallMirrors;
use uv_shell::{shlex_posix, shlex_windows, Shell}; use uv_shell::{shlex_posix, shlex_windows, Shell};
use uv_types::{AnyErrorBuild, BuildContext, BuildIsolation, BuildStack, HashStrategy}; use uv_types::{AnyErrorBuild, BuildContext, BuildIsolation, BuildStack, HashStrategy};
use uv_virtualenv::VenvForceMode;
use uv_warnings::warn_user; use uv_warnings::warn_user;
use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceError}; use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceCache, WorkspaceError};
@ -58,6 +59,7 @@ pub(crate) async fn venv(
prompt: uv_virtualenv::Prompt, prompt: uv_virtualenv::Prompt,
system_site_packages: bool, system_site_packages: bool,
seed: bool, seed: bool,
force: VenvForceMode,
allow_existing: bool, allow_existing: bool,
exclude_newer: Option<ExcludeNewer>, exclude_newer: Option<ExcludeNewer>,
concurrency: Concurrency, concurrency: Concurrency,
@ -84,6 +86,7 @@ pub(crate) async fn venv(
seed, seed,
python_preference, python_preference,
python_downloads, python_downloads,
force,
allow_existing, allow_existing,
exclude_newer, exclude_newer,
concurrency, concurrency,
@ -141,6 +144,7 @@ async fn venv_impl(
seed: bool, seed: bool,
python_preference: PythonPreference, python_preference: PythonPreference,
python_downloads: PythonDownloads, python_downloads: PythonDownloads,
force: VenvForceMode,
allow_existing: bool, allow_existing: bool,
exclude_newer: Option<ExcludeNewer>, exclude_newer: Option<ExcludeNewer>,
concurrency: Concurrency, concurrency: Concurrency,
@ -271,6 +275,7 @@ async fn venv_impl(
prompt, prompt,
system_site_packages, system_site_packages,
allow_existing, allow_existing,
force,
relocatable, relocatable,
seed, seed,
) )

View file

@ -982,6 +982,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
uv_virtualenv::Prompt::from_args(prompt), uv_virtualenv::Prompt::from_args(prompt),
args.system_site_packages, args.system_site_packages,
args.seed, args.seed,
args.force,
args.allow_existing, args.allow_existing,
args.settings.exclude_newer, args.settings.exclude_newer,
globals.concurrency, globals.concurrency,

View file

@ -43,6 +43,7 @@ use uv_settings::{
}; };
use uv_static::EnvVars; use uv_static::EnvVars;
use uv_torch::TorchMode; use uv_torch::TorchMode;
use uv_virtualenv::VenvForceMode;
use uv_warnings::warn_user_once; use uv_warnings::warn_user_once;
use uv_workspace::pyproject::DependencyType; use uv_workspace::pyproject::DependencyType;
@ -2390,6 +2391,7 @@ impl BuildSettings {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) struct VenvSettings { pub(crate) struct VenvSettings {
pub(crate) seed: bool, pub(crate) seed: bool,
pub(crate) force: VenvForceMode,
pub(crate) allow_existing: bool, pub(crate) allow_existing: bool,
pub(crate) path: Option<PathBuf>, pub(crate) path: Option<PathBuf>,
pub(crate) prompt: Option<String>, pub(crate) prompt: Option<String>,
@ -2408,6 +2410,7 @@ impl VenvSettings {
system, system,
no_system, no_system,
seed, seed,
force,
allow_existing, allow_existing,
path, path,
prompt, prompt,
@ -2425,6 +2428,7 @@ impl VenvSettings {
Self { Self {
seed, seed,
force: VenvForceMode::from_args(force),
allow_existing, allow_existing,
path, path,
prompt, prompt,