Add support for Python version templating

This commit is contained in:
Zanie Blue 2025-07-28 09:07:17 -05:00
parent 9cec7486c8
commit c297af5922
7 changed files with 136 additions and 50 deletions

1
Cargo.lock generated
View File

@ -6074,6 +6074,7 @@ dependencies = [
"uv-pep440", "uv-pep440",
"uv-pep508", "uv-pep508",
"uv-pypi-types", "uv-pypi-types",
"uv-python",
"uv-redacted", "uv-redacted",
"uv-static", "uv-static",
"uv-warnings", "uv-warnings",

View File

@ -28,6 +28,7 @@ uv-options-metadata = { workspace = true }
uv-pep440 = { workspace = true } uv-pep440 = { workspace = true }
uv-pep508 = { workspace = true } uv-pep508 = { workspace = true }
uv-pypi-types = { workspace = true } uv-pypi-types = { workspace = true }
uv-python = { workspace = true }
uv-redacted = { workspace = true } uv-redacted = { workspace = true }
uv-static = { workspace = true } uv-static = { workspace = true }
uv-warnings = { workspace = true } uv-warnings = { workspace = true }

View File

@ -1,6 +1,7 @@
pub use workspace::{ pub use workspace::{
DiscoveryOptions, MemberDiscovery, ProjectWorkspace, RequiresPythonSources, VirtualProject, CustomProjectEnvironmentPath, DiscoveryOptions, MemberDiscovery, ProjectWorkspace,
Workspace, WorkspaceCache, WorkspaceError, WorkspaceMember, RequiresPythonSources, VirtualProject, Workspace, WorkspaceCache, WorkspaceError,
WorkspaceMember,
}; };
pub mod dependency_groups; pub mod dependency_groups;

View File

@ -1,6 +1,7 @@
//! Resolve the current [`ProjectWorkspace`] or [`Workspace`]. //! Resolve the current [`ProjectWorkspace`] or [`Workspace`].
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::ops::Deref;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
@ -16,6 +17,7 @@ use uv_normalize::{DEV_DEPENDENCIES, GroupName, PackageName};
use uv_pep440::VersionSpecifiers; use uv_pep440::VersionSpecifiers;
use uv_pep508::{MarkerTree, VerbatimUrl}; use uv_pep508::{MarkerTree, VerbatimUrl};
use uv_pypi_types::{Conflicts, SupportedEnvironments, VerbatimParsedUrl}; use uv_pypi_types::{Conflicts, SupportedEnvironments, VerbatimParsedUrl};
use uv_python::Interpreter;
use uv_static::EnvVars; use uv_static::EnvVars;
use uv_warnings::warn_user_once; 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 /// 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 /// warnings about mismatch between the active environment and the project environment will be
/// silenced. /// silenced.
pub fn venv(&self, active: Option<bool>, preview: Preview) -> PathBuf { pub fn venv(
&self,
active: Option<bool>,
python: Option<&Interpreter>,
preview: Preview,
) -> PathBuf {
/// Resolve the `UV_PROJECT_ENVIRONMENT` value, if any. /// Resolve the `UV_PROJECT_ENVIRONMENT` value, if any.
fn from_project_environment_variable( fn from_project_environment_variable(
workspace: &Workspace, workspace: &Workspace,
python: Option<&Interpreter>,
preview: Preview, preview: Preview,
) -> Option<PathBuf> { ) -> Option<PathBuf> {
let value = std::env::var_os(EnvVars::UV_PROJECT_ENVIRONMENT)?; let value = CustomProjectEnvironmentPath::from_env()?.into_os_string();
if value.is_empty() {
return None;
}
let templated = value let templated = value
.to_string_lossy() .to_string_lossy()
@ -658,6 +662,24 @@ impl Workspace {
.as_ref() .as_ref()
.map(|project| project.name.to_string()) .map(|project| project.name.to_string())
.unwrap_or("none".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() { let path = if templated != value.to_string_lossy() {
@ -699,7 +721,7 @@ impl Workspace {
} }
// Determine the default value // 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")); .unwrap_or_else(|| self.install_path.join(".venv"));
// Warn if it conflicts with `VIRTUAL_ENV` // 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<Self> {
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. /// The current project and the workspace it is part of, with all of the workspace members.
/// ///
/// # Structure /// # Structure

View File

@ -47,7 +47,9 @@ use uv_virtualenv::remove_virtualenv;
use uv_warnings::{warn_user, warn_user_once}; use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::dependency_groups::DependencyGroupError; use uv_workspace::dependency_groups::DependencyGroupError;
use uv_workspace::pyproject::PyProjectToml; 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::loggers::{InstallLogger, ResolveLogger};
use crate::commands::pip::operations::{Changelog, Modifications}; use crate::commands::pip::operations::{Changelog, Modifications};
@ -903,8 +905,50 @@ impl ProjectInterpreter {
) )
.await?; .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. // 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) { match PythonEnvironment::from_root(&root, cache) {
Ok(venv) => { Ok(venv) => {
match environment_is_usable( match environment_is_usable(
@ -972,29 +1016,12 @@ impl ProjectInterpreter {
Err(err) => return Err(err.into()), 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. // Locate the Python interpreter to use in the environment.
let python = PythonInstallation::find_or_download( let python = if let Some(python) = python {
python_request.as_ref(), python
EnvironmentPreference::OnlySystem, } else {
python_preference, find_python().await?
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 managed = python.source().is_managed(); let managed = python.source().is_managed();
let implementation = python.implementation(); let implementation = python.implementation();
@ -1307,7 +1334,7 @@ impl ProjectEnvironment {
// Otherwise, create a virtual environment with the discovered interpreter. // Otherwise, create a virtual environment with the discovered interpreter.
ProjectInterpreter::Interpreter(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 // Avoid removing things that are not virtual environments
let replace = match (root.try_exists(), root.join("pyvenv.cfg").try_exists()) { let replace = match (root.try_exists(), root.join("pyvenv.cfg").try_exists()) {

View File

@ -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 // 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 // to map into a miette diagnostic. We should just remove miette diagnostics here, we're not
// using them elsewhere. // using them elsewhere.
@ -174,6 +160,23 @@ pub(crate) async fn venv(
python.into_interpreter() 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(); index_locations.cache_index_credentials();
// Check if the discovered Python version is incompatible with the current workspace // Check if the discovered Python version is incompatible with the current workspace

View File

@ -7320,7 +7320,7 @@ fn preview_features() {
show_settings: true, show_settings: true,
preview: Preview { preview: Preview {
flags: PreviewFeatures( 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, python_preference: Managed,
@ -7524,7 +7524,7 @@ fn preview_features() {
show_settings: true, show_settings: true,
preview: Preview { preview: Preview {
flags: PreviewFeatures( 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, python_preference: Managed,