diff --git a/Cargo.lock b/Cargo.lock index a345b9ab5..dee936e3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6074,6 +6074,7 @@ dependencies = [ "uv-pep440", "uv-pep508", "uv-pypi-types", + "uv-python", "uv-redacted", "uv-static", "uv-warnings", diff --git a/crates/uv-workspace/Cargo.toml b/crates/uv-workspace/Cargo.toml index a00662f14..e3034a141 100644 --- a/crates/uv-workspace/Cargo.toml +++ b/crates/uv-workspace/Cargo.toml @@ -28,6 +28,7 @@ uv-options-metadata = { workspace = true } uv-pep440 = { workspace = true } uv-pep508 = { workspace = true } uv-pypi-types = { workspace = true } +uv-python = { workspace = true } uv-redacted = { workspace = true } uv-static = { workspace = true } uv-warnings = { workspace = true } diff --git a/crates/uv-workspace/src/lib.rs b/crates/uv-workspace/src/lib.rs index 0e1b3974c..5260277d1 100644 --- a/crates/uv-workspace/src/lib.rs +++ b/crates/uv-workspace/src/lib.rs @@ -1,6 +1,7 @@ pub use workspace::{ - DiscoveryOptions, MemberDiscovery, ProjectWorkspace, RequiresPythonSources, VirtualProject, - Workspace, WorkspaceCache, WorkspaceError, WorkspaceMember, + CustomProjectEnvironmentPath, DiscoveryOptions, MemberDiscovery, ProjectWorkspace, + RequiresPythonSources, VirtualProject, Workspace, WorkspaceCache, WorkspaceError, + WorkspaceMember, }; pub mod dependency_groups; diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 73c5f8821..6841770be 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -1,6 +1,7 @@ //! Resolve the current [`ProjectWorkspace`] or [`Workspace`]. use std::collections::{BTreeMap, BTreeSet}; +use std::ops::Deref; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -16,6 +17,7 @@ use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName}; use uv_pep440::VersionSpecifiers; use uv_pep508::{MarkerTree, VerbatimUrl}; use uv_pypi_types::{Conflicts, SupportedEnvironments, VerbatimParsedUrl}; +use uv_python::Interpreter; use uv_static::EnvVars; use uv_warnings::warn_user_once; @@ -632,17 +634,19 @@ impl Workspace { /// If `active` is `true`, the `VIRTUAL_ENV` variable will be preferred. If it is `false`, any /// warnings about mismatch between the active environment and the project environment will be /// silenced. - pub fn venv(&self, active: Option, preview: Preview) -> PathBuf { + pub fn venv( + &self, + active: Option, + python: Option<&Interpreter>, + preview: Preview, + ) -> PathBuf { /// Resolve the `UV_PROJECT_ENVIRONMENT` value, if any. fn from_project_environment_variable( workspace: &Workspace, + python: Option<&Interpreter>, preview: Preview, ) -> Option { - let value = std::env::var_os(EnvVars::UV_PROJECT_ENVIRONMENT)?; - - if value.is_empty() { - return None; - } + let value = CustomProjectEnvironmentPath::from_env()?.into_os_string(); let templated = value .to_string_lossy() @@ -658,6 +662,24 @@ impl Workspace { .as_ref() .map(|project| project.name.to_string()) .unwrap_or("none".to_string()), + ) + .replace( + "{python_version_minor}", + &python + .map(|python| python.python_minor_version().to_string()) + .unwrap_or("none".to_string()), + ) + .replace( + "{python_implementation}", + &python + .map(|python| python.implementation_name().to_lowercase().to_string()) + .unwrap_or("none".to_string()), + ) + .replace( + "{python_version_full}", + &python + .map(|python| python.python_full_version().to_string()) + .unwrap_or("none".to_string()), ); let path = if templated != value.to_string_lossy() { @@ -699,7 +721,7 @@ impl Workspace { } // Determine the default value - let project_env = from_project_environment_variable(self, preview) + let project_env = from_project_environment_variable(self, python, preview) .unwrap_or_else(|| self.install_path.join(".venv")); // Warn if it conflicts with `VIRTUAL_ENV` @@ -1087,6 +1109,37 @@ impl WorkspaceMember { } } +pub struct CustomProjectEnvironmentPath(std::ffi::OsString); + +impl CustomProjectEnvironmentPath { + /// Returns the value of the `UV_PROJECT_ENVIRONMENT` environment variable, if set. + pub fn from_env() -> Option { + std::env::var_os(EnvVars::UV_PROJECT_ENVIRONMENT) + .filter(|value| !value.is_empty()) + .map(Self) + } + + pub fn into_os_string(self) -> std::ffi::OsString { + self.0 + } + + /// Whether this path contains any Python interpreter template variables. + pub fn contains_python_template(&self) -> bool { + let as_str = self.0.to_string_lossy(); + as_str.contains("{python_version_minor}") + || as_str.contains("{python_version_full}") + || as_str.contains("{python_implementation}") + } +} + +impl Deref for CustomProjectEnvironmentPath { + type Target = std::ffi::OsStr; + + fn deref(&self) -> &Self::Target { + self.0.as_os_str() + } +} + /// The current project and the workspace it is part of, with all of the workspace members. /// /// # Structure diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index fbc523283..c9f04083d 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -47,7 +47,9 @@ use uv_virtualenv::remove_virtualenv; use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::dependency_groups::DependencyGroupError; use uv_workspace::pyproject::PyProjectToml; -use uv_workspace::{RequiresPythonSources, Workspace, WorkspaceCache}; +use uv_workspace::{ + CustomProjectEnvironmentPath, RequiresPythonSources, Workspace, WorkspaceCache, +}; use crate::commands::pip::loggers::{InstallLogger, ResolveLogger}; use crate::commands::pip::operations::{Changelog, Modifications}; @@ -903,8 +905,50 @@ impl ProjectInterpreter { ) .await?; + let client_builder = BaseClientBuilder::default() + .retries_from_env()? + .connectivity(network_settings.connectivity) + .native_tls(network_settings.native_tls) + .allow_insecure_host(network_settings.allow_insecure_host.clone()); + + let reporter = PythonDownloadReporter::single(printer); + + // Locate the Python interpreter to use in the environment. + let find_python = || { + PythonInstallation::find_or_download( + python_request.as_ref(), + EnvironmentPreference::OnlySystem, + python_preference, + python_downloads, + &client_builder, + cache, + Some(&reporter), + install_mirrors.python_install_mirror.as_deref(), + install_mirrors.pypy_install_mirror.as_deref(), + install_mirrors.python_downloads_json_url.as_deref(), + preview, + ) + }; + + // If we need an interpreter to determine the environment path, discover it _first_. + // Otherwise, we defer this case because we may be be able to skip searching for an + // interpreter. + let python = if CustomProjectEnvironmentPath::from_env() + .as_ref() + .is_some_and(CustomProjectEnvironmentPath::contains_python_template) + { + // Locate the Python interpreter to use in the environment. + Some(find_python().await?) + } else { + None + }; + // Read from the virtual environment first. - let root = workspace.venv(active, preview); + let root = workspace.venv( + active, + python.as_ref().map(PythonInstallation::interpreter), + preview, + ); match PythonEnvironment::from_root(&root, cache) { Ok(venv) => { match environment_is_usable( @@ -972,29 +1016,12 @@ impl ProjectInterpreter { Err(err) => return Err(err.into()), } - let client_builder = BaseClientBuilder::default() - .retries_from_env()? - .connectivity(network_settings.connectivity) - .native_tls(network_settings.native_tls) - .allow_insecure_host(network_settings.allow_insecure_host.clone()); - - let reporter = PythonDownloadReporter::single(printer); - // Locate the Python interpreter to use in the environment. - let python = PythonInstallation::find_or_download( - python_request.as_ref(), - EnvironmentPreference::OnlySystem, - python_preference, - python_downloads, - &client_builder, - cache, - Some(&reporter), - install_mirrors.python_install_mirror.as_deref(), - install_mirrors.pypy_install_mirror.as_deref(), - install_mirrors.python_downloads_json_url.as_deref(), - preview, - ) - .await?; + let python = if let Some(python) = python { + python + } else { + find_python().await? + }; let managed = python.source().is_managed(); let implementation = python.implementation(); @@ -1307,7 +1334,7 @@ impl ProjectEnvironment { // Otherwise, create a virtual environment with the discovered interpreter. ProjectInterpreter::Interpreter(interpreter) => { - let root = workspace.venv(active, preview); + let root = workspace.venv(active, Some(&interpreter), preview); // Avoid removing things that are not virtual environments let replace = match (root.try_exists(), root.join("pyvenv.cfg").try_exists()) { diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 9daec9939..665c26d7f 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -110,20 +110,6 @@ pub(crate) async fn venv( } }; - // 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() == project_dir) - .then(|| project.workspace().venv(Some(false), preview)) - }) - .unwrap_or(PathBuf::from(".venv")), - ); - // TODO(zanieb): We don't use [`BaseClientBuilder::retries_from_env`] here because it's a pain // to map into a miette diagnostic. We should just remove miette diagnostics here, we're not // using them elsewhere. @@ -174,6 +160,23 @@ pub(crate) async fn venv( python.into_interpreter() }; + // 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() == project_dir).then(|| { + project + .workspace() + .venv(Some(false), Some(&interpreter), preview) + }) + }) + .unwrap_or(PathBuf::from(".venv")), + ); + index_locations.cache_index_credentials(); // Check if the discovered Python version is incompatible with the current workspace diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index 65555ba56..e8ec50677 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -7320,7 +7320,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | TEMPLATE_PROJECT_ENVIRONMENT, ), }, python_preference: Managed, @@ -7524,7 +7524,7 @@ fn preview_features() { show_settings: true, preview: Preview { flags: PreviewFeatures( - PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS, + PYTHON_INSTALL_DEFAULT | PYTHON_UPGRADE | JSON_OUTPUT | PYLOCK | ADD_BOUNDS | TEMPLATE_PROJECT_ENVIRONMENT, ), }, python_preference: Managed,