diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index ce0f2f5c4aae..d19f30c0ad26 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -322,6 +322,10 @@ pub enum Commands { /// By default, creates a virtual environment named `.venv` in the working /// directory. An alternative path may be provided positionally. /// + /// If in a project, the default environment name can be changed with + /// the `UV_PROJECT_ENVIRONMENT` environment variable; this only applies + /// when run from the project root directory. + /// /// If a virtual environment exists at the target path, it will be removed /// and a new, empty virtual environment will be created. /// @@ -1961,6 +1965,14 @@ pub struct VenvArgs { #[arg(long, overrides_with("system"), hide = true)] pub no_system: bool, + /// Avoid discovering a project or workspace. + /// + /// By default, uv searches for projects in the current directory or any parent directory to + /// determine the default path of the virtual environment and check for Python version + /// constraints, if any. + #[arg(long, alias = "no-workspace")] + pub no_project: bool, + /// Install seed packages (one or more of: `pip`, `setuptools`, and `wheel`) into the virtual environment. /// /// Note `setuptools` and `wheel` are not included in Python 3.12+ environments. @@ -1980,8 +1992,11 @@ pub struct VenvArgs { pub allow_existing: bool, /// The path to the virtual environment to create. - #[arg(default_value = ".venv")] - pub name: PathBuf, + /// + /// Default to `.venv` in the working directory. + /// + /// Relative paths are resolved relative to the working directory. + pub path: Option, /// Provide an alternative prompt prefix for the virtual environment. /// diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 9eee14abe6bd..2967f72bf053 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -1,5 +1,5 @@ use std::fmt::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::vec; @@ -41,7 +41,7 @@ use crate::printer::Printer; /// Create a virtual environment. #[allow(clippy::unnecessary_wraps, clippy::fn_params_excessive_bools)] pub(crate) async fn venv( - path: &Path, + path: Option, python_request: Option<&str>, python_preference: PythonPreference, python_downloads: PythonDownloads, @@ -59,6 +59,7 @@ pub(crate) async fn venv( concurrency: Concurrency, native_tls: bool, no_config: bool, + no_project: bool, cache: &Cache, printer: Printer, relocatable: bool, @@ -82,6 +83,7 @@ pub(crate) async fn venv( concurrency, native_tls, no_config, + no_project, cache, printer, relocatable, @@ -118,7 +120,7 @@ enum VenvError { /// Create a virtual environment. #[allow(clippy::fn_params_excessive_bools)] async fn venv_impl( - path: &Path, + path: Option, python_request: Option<&str>, link_mode: LinkMode, index_locations: &IndexLocations, @@ -136,10 +138,39 @@ async fn venv_impl( concurrency: Concurrency, native_tls: bool, no_config: bool, + no_project: bool, cache: &Cache, printer: Printer, relocatable: bool, ) -> miette::Result { + let project = if no_project { + None + } else { + match VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await { + Ok(project) => Some(project), + Err(WorkspaceError::MissingProject(_)) => None, + Err(WorkspaceError::MissingPyprojectToml) => None, + Err(WorkspaceError::NonWorkspace(_)) => None, + Err(err) => { + warn_user_once!("{err}"); + None + } + } + }; + + // Determine the default path; either the virtual environment for the project or `.venv` + let path = path.unwrap_or( + project + .as_ref() + .and_then(|project| { + // Only use the project environment path if we're invoked from the root + // This isn't strictly necessary and we may want to change it later, but this + // avoids a breaking change when adding project environment support to `uv venv`. + (project.workspace().install_path() == &*CWD).then(|| project.workspace().venv()) + }) + .unwrap_or(PathBuf::from(".venv")), + ); + let client_builder = BaseClientBuilder::default() .connectivity(connectivity) .native_tls(native_tls); @@ -159,17 +190,6 @@ async fn venv_impl( // (3) `Requires-Python` in `pyproject.toml` if interpreter_request.is_none() { - let project = match VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await { - Ok(project) => Some(project), - Err(WorkspaceError::MissingProject(_)) => None, - Err(WorkspaceError::MissingPyprojectToml) => None, - Err(WorkspaceError::NonWorkspace(_)) => None, - Err(err) => { - warn_user_once!("{err}"); - None - } - }; - if let Some(project) = project { interpreter_request = find_requires_python(project.workspace()) .into_diagnostic()? @@ -229,7 +249,7 @@ async fn venv_impl( // Create the virtual environment. let venv = uv_virtualenv::create_venv( - path, + &path, interpreter, prompt, system_site_packages, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 66a66e25a4e8..92d470953d95 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1,7 +1,6 @@ use std::ffi::OsString; use std::fmt::Write; use std::io::stdout; -use std::path::PathBuf; use std::process::ExitCode; use anstream::eprintln; @@ -680,7 +679,7 @@ async fn run(cli: Cli) -> Result { // Since we use ".venv" as the default name, we use "." as the default prompt. let prompt = args.prompt.or_else(|| { - if args.name == PathBuf::from(".venv") { + if args.path.is_none() { Some(".".to_string()) } else { None @@ -688,7 +687,7 @@ async fn run(cli: Cli) -> Result { }); commands::venv( - &args.name, + args.path, args.settings.python.as_deref(), globals.python_preference, globals.python_downloads, @@ -706,6 +705,7 @@ async fn run(cli: Cli) -> Result { globals.concurrency, globals.native_tls, cli.no_config, + args.no_project, &cache, printer, args.relocatable, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index c8a39d736f72..55302c500531 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -1614,10 +1614,11 @@ impl PipCheckSettings { pub(crate) struct VenvSettings { pub(crate) seed: bool, pub(crate) allow_existing: bool, - pub(crate) name: PathBuf, + pub(crate) path: Option, pub(crate) prompt: Option, pub(crate) system_site_packages: bool, pub(crate) relocatable: bool, + pub(crate) no_project: bool, pub(crate) settings: PipSettings, } @@ -1630,7 +1631,7 @@ impl VenvSettings { no_system, seed, allow_existing, - name, + path, prompt, system_site_packages, relocatable, @@ -1639,6 +1640,7 @@ impl VenvSettings { keyring_provider, allow_insecure_host, exclude_newer, + no_project, link_mode, compat_args: _, } = args; @@ -1646,9 +1648,10 @@ impl VenvSettings { Self { seed, allow_existing, - name, + path, prompt, system_site_packages, + no_project, relocatable, settings: PipSettings::combine( PipOptions { diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index b948797eb5e2..4316658411b5 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -335,8 +335,8 @@ impl TestContext { // Make virtual environment activation cross-platform and shell-agnostic filters.push(( - r"Activate with: (?:.*)\\Scripts\\activate".to_string(), - "Activate with: source .venv/bin/activate".to_string(), + r"Activate with: (.*)\\Scripts\\activate".to_string(), + "Activate with: source $1/bin/activate".to_string(), )); filters.push(( r"Activate with: source .venv/bin/activate(?:\.\w+)".to_string(), diff --git a/crates/uv/tests/venv.rs b/crates/uv/tests/venv.rs index c8c0508303f7..bed96f55ee1d 100644 --- a/crates/uv/tests/venv.rs +++ b/crates/uv/tests/venv.rs @@ -52,10 +52,10 @@ fn create_venv() { } #[test] -fn create_venv_uv_project_environment() -> Result<()> { +fn create_venv_project_environment() -> Result<()> { let context = TestContext::new_with_versions(&["3.12"]); - // `uv venv` ignores UV_PROJECT_ENVIRONMENT + // `uv venv` ignores `UV_PROJECT_ENVIRONMENT` when it's not a project uv_snapshot!(context.filters(), context.venv().env("UV_PROJECT_ENVIRONMENT", "foo"), @r###" success: true exit_code: 0 @@ -74,14 +74,44 @@ fn create_venv_uv_project_environment() -> Result<()> { .child("foo") .assert(predicates::path::missing()); - context.temp_dir.child("pyproject.toml").touch()?; + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#, + )?; - // Even if there's a `pyproject.toml` + // But, if we're in a project we'll respect it uv_snapshot!(context.filters(), context.venv().env("UV_PROJECT_ENVIRONMENT", "foo"), @r###" success: true exit_code: 0 ----- stdout ----- + ----- stderr ----- + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtualenv at: foo + Activate with: source foo/bin/activate + "### + ); + + context + .temp_dir + .child("foo") + .assert(predicates::path::is_dir()); + + // Unless we're in a child directory + let child = context.temp_dir.child("child"); + child.create_dir_all()?; + + uv_snapshot!(context.filters(), context.venv().env("UV_PROJECT_ENVIRONMENT", "foo").current_dir(child.path()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + ----- stderr ----- Using Python 3.12.[X] interpreter at: [PYTHON-3.12] Creating virtualenv at: .venv @@ -89,10 +119,52 @@ fn create_venv_uv_project_environment() -> Result<()> { "### ); + // In which case, we'll use the default name of `.venv` + child.child("foo").assert(predicates::path::missing()); + child.child(".venv").assert(predicates::path::is_dir()); + + // Or, if a name is provided + uv_snapshot!(context.filters(), context.venv().arg("bar"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtualenv at: bar + Activate with: source bar/bin/activate + "### + ); + context .temp_dir - .child("foo") - .assert(predicates::path::missing()); + .child("bar") + .assert(predicates::path::is_dir()); + + // Or, of they opt-out with `--no-workspace` or `--no-project` + uv_snapshot!(context.filters(), context.venv().arg("--no-workspace"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtualenv at: .venv + Activate with: source .venv/bin/activate + "### + ); + + uv_snapshot!(context.filters(), context.venv().arg("--no-project"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtualenv at: .venv + Activate with: source .venv/bin/activate + "### + ); Ok(()) } diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 4b3545eb6769..254d497c8185 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5956,6 +5956,8 @@ Create a virtual environment. By default, creates a virtual environment named `.venv` in the working directory. An alternative path may be provided positionally. +If in a project, the default environment name can be changed with the `UV_PROJECT_ENVIRONMENT` environment variable; this only applies when run from the project root directory. + If a virtual environment exists at the target path, it will be removed and a new, empty virtual environment will be created. When using uv, the virtual environment does not need to be activated. uv will find a virtual environment (named `.venv`) in the working directory or any parent directories. @@ -5963,12 +5965,16 @@ When using uv, the virtual environment does not need to be activated. uv will fi

Usage

``` -uv venv [OPTIONS] [NAME] +uv venv [OPTIONS] [PATH] ```

Arguments

-
NAME

The path to the virtual environment to create

+
PATH

The path to the virtual environment to create.

+ +

Default to .venv in the working directory.

+ +

Relative paths are resolved relative to the working directory.

@@ -6105,6 +6111,10 @@ uv venv [OPTIONS] [NAME]

For example, spinners or progress bars.

+
--no-project

Avoid discovering a project or workspace.

+ +

By default, uv searches for projects in the current directory or any parent directory to determine the default path of the virtual environment and check for Python version constraints, if any.

+
--no-python-downloads

Disable automatic downloads of Python.

--offline

Disable network access.