Improve project handling in uv venv (#6835)

- Respect `UV_PROJECT_ENVIRONMENT` when in project root
- Add `--no-project` and `--no-workspace` to opt-out of above and
`requires-python` detection
- Rename `[NAME]` to `[PATH]` in CLI
This commit is contained in:
Zanie Blue 2024-09-03 14:22:30 -05:00 committed by GitHub
parent 1e89d3e44f
commit d87256bebe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 153 additions and 33 deletions

View file

@ -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<PathBuf>,
/// Provide an alternative prompt prefix for the virtual environment.
///

View file

@ -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<PathBuf>,
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<PathBuf>,
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<ExitStatus> {
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,

View file

@ -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<ExitStatus> {
// 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<ExitStatus> {
});
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<ExitStatus> {
globals.concurrency,
globals.native_tls,
cli.no_config,
args.no_project,
&cache,
printer,
args.relocatable,

View file

@ -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<PathBuf>,
pub(crate) prompt: Option<String>,
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 {

View file

@ -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(),

View file

@ -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(())
}

View file

@ -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
<h3 class="cli-reference">Usage</h3>
```
uv venv [OPTIONS] [NAME]
uv venv [OPTIONS] [PATH]
```
<h3 class="cli-reference">Arguments</h3>
<dl class="cli-reference"><dt><code>NAME</code></dt><dd><p>The path to the virtual environment to create</p>
<dl class="cli-reference"><dt><code>PATH</code></dt><dd><p>The path to the virtual environment to create.</p>
<p>Default to <code>.venv</code> in the working directory.</p>
<p>Relative paths are resolved relative to the working directory.</p>
</dd></dl>
@ -6105,6 +6111,10 @@ uv venv [OPTIONS] [NAME]
<p>For example, spinners or progress bars.</p>
</dd><dt><code>--no-project</code></dt><dd><p>Avoid discovering a project or workspace.</p>
<p>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.</p>
</dd><dt><code>--no-python-downloads</code></dt><dd><p>Disable automatic downloads of Python.</p>
</dd><dt><code>--offline</code></dt><dd><p>Disable network access.</p>