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
8 changes: 8 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2689,6 +2689,14 @@ pub struct VenvArgs {
#[clap(long, short, overrides_with = "allow_existing", value_parser = clap::builder::BoolishValueParser::new(), env = EnvVars::UV_VENV_CLEAR)]
pub clear: bool,

/// Disable clearing and exit with error if target path is non-empty.
///
/// By default, `uv venv` will prompt to clear a non-empty directory (when a TTY is available)
/// or exit with an error (when no TTY is available). The `--no-clear` option will force
/// an error exit without prompting, regardless of TTY availability.
#[clap(long, overrides_with = "clear", conflicts_with = "allow_existing")]
pub no_clear: bool,

/// Preserve any existing files or directories at the target path.
///
/// By default, `uv venv` will exit with an error if the given path is non-empty. The
Expand Down
103 changes: 64 additions & 39 deletions crates/uv-virtualenv/src/virtualenv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,43 +118,61 @@ pub(crate) fn create(
remove_virtualenv(&location)?;
fs::create_dir_all(&location)?;
}
OnExisting::Fail => {
match confirm_clear(location, name)? {
Some(true) => {
debug!("Removing existing {name} due to confirmation");
// Before removing the virtual environment, we need to canonicalize the
// path because `Path::metadata` will follow the symlink but we're still
// operating on the unresolved path and will remove the symlink itself.
let location = location
.canonicalize()
.unwrap_or_else(|_| location.to_path_buf());
remove_virtualenv(&location)?;
fs::create_dir_all(&location)?;
}
Some(false) => {
let hint = format!(
"Use the `{}` flag or set `{}` to replace the existing {name}",
"--clear".green(),
"UV_VENV_CLEAR=1".green()
);
return Err(Error::Io(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"A {name} already exists at: {}\n\n{}{} {hint}",
OnExisting::Fail(allow_prompt) => {
if allow_prompt {
match confirm_clear(location, name)? {
Comment on lines +122 to +123
Copy link
Member

@zanieb zanieb Aug 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd use match allow_prompt.then(|| confirm_clear(..)).flatten() so we don't need to indent the match and repeat the error message construction.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(You might need .transpose()?.flatten() to handle the error still)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved at #15795

Some(true) => {
debug!("Removing existing {name} due to confirmation");
// Before removing the virtual environment, we need to canonicalize the
// path because `Path::metadata` will follow the symlink but we're still
// operating on the unresolved path and will remove the symlink itself.
let location = location
.canonicalize()
.unwrap_or_else(|_| location.to_path_buf());
remove_virtualenv(&location)?;
fs::create_dir_all(&location)?;
}
Some(false) => {
let hint = format!(
"Use the `{}` flag or set `{}` to replace the existing {name}",
"--clear".green(),
"UV_VENV_CLEAR=1".green()
);
return Err(Error::Io(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"A {name} already exists at: {}\n\n{}{} {hint}",
location.user_display(),
"hint".bold().cyan(),
":".bold(),
),
)));
}
// When we don't have a TTY, warn that the behavior will change in the future
None => {
warn_user_once!(
"A {name} already exists at `{}`. In the future, uv will require `{}` to replace it",
location.user_display(),
"hint".bold().cyan(),
":".bold(),
),
)));
"--clear".green(),
);
}
}
// When we don't have a TTY, warn that the behavior will change in the future
None => {
warn_user_once!(
"A {name} already exists at `{}`. In the future, uv will require `{}` to replace it",
} else {
// --no-clear was specified, fail without prompting
let hint = format!(
"Use the `{}` flag to clear the {name} or `{}` to allow overwriting",
"--clear".green(),
"--allow-existing".green()
);
return Err(Error::Io(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"A {name} already exists at {}\n\n{}{} {hint}",
location.user_display(),
"--clear".green(),
);
}
"error".bold().red(),
":".bold(),
),
)));
}
}
}
Expand Down Expand Up @@ -623,26 +641,33 @@ pub fn remove_virtualenv(location: &Path) -> Result<(), Error> {
Ok(())
}

#[derive(Debug, Default, Copy, Clone, Eq, PartialEq)]
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum OnExisting {
/// Fail if the directory already exists and is non-empty.
#[default]
Fail,
/// The bool parameter controls whether prompting is allowed.
Fail(bool),
/// Allow an existing directory, overwriting virtual environment files while retaining other
/// files in the directory.
Allow,
/// Remove an existing directory.
Remove,
}

impl Default for OnExisting {
fn default() -> Self {
Self::Fail(true) // Allow prompting by default
}
}

impl OnExisting {
pub fn from_args(allow_existing: bool, clear: bool) -> Self {
pub fn from_args(allow_existing: bool, clear: bool, no_clear: bool) -> Self {
if allow_existing {
Self::Allow
} else if clear {
Self::Remove
} else {
Self::default()
// If no_clear is true, don't allow prompting
Self::Fail(!no_clear)
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1023,7 +1023,11 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
let python_request: Option<PythonRequest> =
args.settings.python.as_deref().map(PythonRequest::parse);

let on_existing = uv_virtualenv::OnExisting::from_args(args.allow_existing, args.clear);
let on_existing = uv_virtualenv::OnExisting::from_args(
args.allow_existing,
args.clear,
args.no_clear,
);

commands::venv(
&project_dir,
Expand Down
3 changes: 3 additions & 0 deletions crates/uv/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2695,6 +2695,7 @@ pub(crate) struct VenvSettings {
pub(crate) seed: bool,
pub(crate) allow_existing: bool,
pub(crate) clear: bool,
pub(crate) no_clear: bool,
pub(crate) path: Option<PathBuf>,
pub(crate) prompt: Option<String>,
pub(crate) system_site_packages: bool,
Expand All @@ -2714,6 +2715,7 @@ impl VenvSettings {
seed,
allow_existing,
clear,
no_clear,
path,
prompt,
system_site_packages,
Expand All @@ -2733,6 +2735,7 @@ impl VenvSettings {
seed,
allow_existing,
clear,
no_clear,
path,
prompt,
system_site_packages,
Expand Down
120 changes: 120 additions & 0 deletions crates/uv/tests/it/venv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1572,3 +1572,123 @@ fn create_venv_nested_symlink_preservation() -> Result<()> {

Ok(())
}

#[test]
fn no_clear_with_existing_directory() {
let context = TestContext::new_with_versions(&["3.12"]);

// Create a virtual environment first
uv_snapshot!(context.filters(), context.venv()
.arg(context.venv.as_os_str())
.arg("--python")
.arg("3.12"), @r###"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"###
);

// Try to create again with --no-clear (should fail)
uv_snapshot!(context.filters(), context.venv()
.arg(context.venv.as_os_str())
.arg("--no-clear")
.arg("--python")
.arg("3.12"), @r"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment at: .venv
error: Failed to create virtual environment
Caused by: A virtual environment already exists at .venv

error: Use the `--clear` flag to clear the virtual environment or `--allow-existing` to allow overwriting
"
);
}

#[test]
fn no_clear_with_non_existent_directory() {
let context = TestContext::new_with_versions(&["3.12"]);

// Create with --no-clear on non-existent directory (should succeed)
uv_snapshot!(context.filters(), context.venv()
.arg(context.venv.as_os_str())
.arg("--no-clear")
.arg("--python")
.arg("3.12"), @r###"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"###
);

context.venv.assert(predicates::path::is_dir());
}

#[test]
fn no_clear_overrides_clear() {
let context = TestContext::new_with_versions(&["3.12"]);

// Create a non-empty directory at `.venv`
context.venv.create_dir_all().unwrap();
context.venv.child("file").touch().unwrap();

// --no-clear should override --clear and fail without prompting
uv_snapshot!(context.filters(), context.venv()
.arg(context.venv.as_os_str())
.arg("--clear")
.arg("--no-clear")
.arg("--python")
.arg("3.12"), @r"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Creating virtual environment at: .venv
error: Failed to create virtual environment
Caused by: A directory already exists at .venv

error: Use the `--clear` flag to clear the directory or `--allow-existing` to allow overwriting
"
);
}

#[test]
fn no_clear_conflicts_with_allow_existing() {
let context = TestContext::new_with_versions(&["3.12"]);

// Try to use --no-clear with --allow-existing (should fail)
uv_snapshot!(context.filters(), context.venv()
.arg(context.venv.as_os_str())
.arg("--no-clear")
.arg("--allow-existing")
.arg("--python")
.arg("3.12"), @r"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
error: the argument '--no-clear' cannot be used with '--allow-existing'

Usage: uv venv --cache-dir [CACHE_DIR] --no-clear --python <PYTHON> --exclude-newer <EXCLUDE_NEWER> <PATH>

For more information, try '--help'.
"
);
}
4 changes: 3 additions & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -4935,7 +4935,9 @@ uv venv [OPTIONS] [PATH]
<p>By default, uv loads certificates from the bundled <code>webpki-roots</code> crate. The <code>webpki-roots</code> are a reliable set of trust roots from Mozilla, and including them in uv improves portability and performance (especially on macOS).</p>
<p>However, in some cases, you may want to use the platform's native certificate store, especially if you're relying on a corporate trust root (e.g., for a mandatory proxy) that's included in your system's certificate store.</p>
<p>May also be set with the <code>UV_NATIVE_TLS</code> environment variable.</p></dd><dt id="uv-venv--no-cache"><a href="#uv-venv--no-cache"><code>--no-cache</code></a>, <code>--no-cache-dir</code>, <code>-n</code></dt><dd><p>Avoid reading from or writing to the cache, instead using a temporary directory for the duration of the operation</p>
<p>May also be set with the <code>UV_NO_CACHE</code> environment variable.</p></dd><dt id="uv-venv--no-config"><a href="#uv-venv--no-config"><code>--no-config</code></a></dt><dd><p>Avoid discovering configuration files (<code>pyproject.toml</code>, <code>uv.toml</code>).</p>
<p>May also be set with the <code>UV_NO_CACHE</code> environment variable.</p></dd><dt id="uv-venv--no-clear"><a href="#uv-venv--no-clear"><code>--no-clear</code></a></dt><dd><p>Disable clearing and exit with error if target path is non-empty.</p>
<p>By default, <code>uv venv</code> will prompt to clear a non-empty directory (when a TTY is available) or exit with an error (when no TTY is available). The <code>--no-clear</code> option will force an error exit without prompting, regardless of TTY availability.</p>
</dd><dt id="uv-venv--no-config"><a href="#uv-venv--no-config"><code>--no-config</code></a></dt><dd><p>Avoid discovering configuration files (<code>pyproject.toml</code>, <code>uv.toml</code>).</p>
<p>Normally, configuration files are discovered in the current directory, parent directories, or user configuration directories.</p>
<p>May also be set with the <code>UV_NO_CONFIG</code> environment variable.</p></dd><dt id="uv-venv--no-index"><a href="#uv-venv--no-index"><code>--no-index</code></a></dt><dd><p>Ignore the registry index (e.g., PyPI), instead relying on direct URL dependencies and those provided via <code>--find-links</code></p>
</dd><dt id="uv-venv--no-managed-python"><a href="#uv-venv--no-managed-python"><code>--no-managed-python</code></a></dt><dd><p>Disable use of uv-managed Python versions.</p>
Expand Down
Loading