From 984c0dc88aded5dd31ba9f2da6e7421729da908c Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Mon, 16 Sep 2024 17:29:42 -0500 Subject: [PATCH] Discover and respect `.python-version` files in parent directories --- crates/uv-python/src/lib.rs | 1 + crates/uv-python/src/version_files.rs | 104 +++++++++--- crates/uv-workspace/src/workspace.rs | 4 + crates/uv/src/commands/build.rs | 12 +- crates/uv/src/commands/project/add.rs | 46 +++--- crates/uv/src/commands/project/export.rs | 1 + crates/uv/src/commands/project/init.rs | 10 +- crates/uv/src/commands/project/lock.rs | 1 + crates/uv/src/commands/project/mod.rs | 199 ++++++++++++++++++++--- crates/uv/src/commands/project/run.rs | 87 ++++------ crates/uv/src/commands/project/tree.rs | 1 + crates/uv/src/commands/python/find.rs | 91 ++++++----- crates/uv/src/commands/python/install.rs | 18 +- crates/uv/src/commands/python/pin.rs | 5 +- crates/uv/src/commands/venv.rs | 57 +++---- crates/uv/tests/python_find.rs | 106 +++++++++++- crates/uv/tests/run.rs | 1 - crates/uv/tests/sync.rs | 137 ++++++++++++++-- crates/uv/tests/venv.rs | 11 ++ 19 files changed, 653 insertions(+), 239 deletions(-) diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index fc691ddf751e6..6b1fb50a9d38b 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -14,6 +14,7 @@ pub use crate::prefix::Prefix; pub use crate::python_version::PythonVersion; pub use crate::target::Target; pub use crate::version_files::{ + DiscoveryOptions as VersionFileDiscoveryOptions, FilePreference as VersionFilePreference, PythonVersionFile, PYTHON_VERSIONS_FILENAME, PYTHON_VERSION_FILENAME, }; pub use crate::virtualenv::{Error as VirtualEnvError, PyVenvConfiguration, VirtualEnvironment}; diff --git a/crates/uv-python/src/version_files.rs b/crates/uv-python/src/version_files.rs index b697f0cce8864..a256ecd7743e9 100644 --- a/crates/uv-python/src/version_files.rs +++ b/crates/uv-python/src/version_files.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use fs_err as fs; use itertools::Itertools; use tracing::debug; +use uv_fs::Simplified; use crate::PythonRequest; @@ -22,38 +23,88 @@ pub struct PythonVersionFile { versions: Vec, } +/// Whether to prefer the `.python-version` or `.python-versions` file. +#[derive(Debug, Clone, Copy, Default)] +pub enum FilePreference { + #[default] + Version, + Versions, +} + +#[derive(Debug, Default, Clone)] +pub struct DiscoveryOptions<'a> { + /// The path to stop discovery at. + stop_discovery_at: Option<&'a Path>, + no_config: bool, + preference: FilePreference, +} + +impl<'a> DiscoveryOptions<'a> { + #[must_use] + pub fn with_no_config(self, no_config: bool) -> Self { + Self { no_config, ..self } + } + + #[must_use] + pub fn with_preference(self, preference: FilePreference) -> Self { + Self { preference, ..self } + } + + #[must_use] + pub fn with_stop_discovery_at(self, stop_discovery_at: Option<&'a Path>) -> Self { + Self { + stop_discovery_at, + ..self + } + } +} + impl PythonVersionFile { - /// Find a Python version file in the given directory. + /// Find a Python version file in the given directory or any of its parents. pub async fn discover( working_directory: impl AsRef, - // TODO(zanieb): Create a `DiscoverySettings` struct for these options - no_config: bool, - prefer_versions: bool, + options: &DiscoveryOptions<'_>, ) -> Result, std::io::Error> { - let versions_path = working_directory.as_ref().join(PYTHON_VERSIONS_FILENAME); - let version_path = working_directory.as_ref().join(PYTHON_VERSION_FILENAME); - - if no_config { - if version_path.exists() { - debug!("Ignoring `.python-version` file due to `--no-config`"); - } else if versions_path.exists() { - debug!("Ignoring `.python-versions` file due to `--no-config`"); - }; + let Some(path) = Self::find_nearest(working_directory, options) else { + return Ok(None); + }; + + if options.no_config { + debug!( + "Ignoring Python version file at `{}` due to `--no-config`", + path.user_display() + ); return Ok(None); } - let paths = if prefer_versions { - [versions_path, version_path] - } else { - [version_path, versions_path] + // Uses `try_from_path` instead of `from_path` to avoid TOCTOU failures. + Self::try_from_path(path).await + } + + fn find_nearest(path: impl AsRef, options: &DiscoveryOptions<'_>) -> Option { + path.as_ref() + .ancestors() + .take_while(|path| { + // Only walk up the given directory, if any. + options + .stop_discovery_at + .and_then(Path::parent) + .map(|stop_discovery_at| stop_discovery_at != *path) + .unwrap_or(true) + }) + .find_map(|path| Self::find_in_directory(path, options)) + } + + fn find_in_directory(path: &Path, options: &DiscoveryOptions<'_>) -> Option { + let version_path = path.join(PYTHON_VERSION_FILENAME); + let versions_path = path.join(PYTHON_VERSIONS_FILENAME); + + let paths = match options.preference { + FilePreference::Versions => [versions_path, version_path], + FilePreference::Version => [version_path, versions_path], }; - for path in paths { - if let Some(result) = Self::try_from_path(path).await? { - return Ok(Some(result)); - }; - } - Ok(None) + paths.into_iter().find(|path| path.is_file()) } /// Try to read a Python version file at the given path. @@ -62,7 +113,10 @@ impl PythonVersionFile { pub async fn try_from_path(path: PathBuf) -> Result, std::io::Error> { match fs::tokio::read_to_string(&path).await { Ok(content) => { - debug!("Reading requests from `{}`", path.display()); + debug!( + "Reading Python requests from version file at `{}`", + path.display() + ); let versions = content .lines() .filter(|line| { @@ -104,7 +158,7 @@ impl PythonVersionFile { } } - /// Return the first version declared in the file, if any. + /// Return the first request declared in the file, if any. pub fn version(&self) -> Option<&PythonRequest> { self.versions.first() } diff --git a/crates/uv-workspace/src/workspace.rs b/crates/uv-workspace/src/workspace.rs index 3b5b3f7c2aaaf..dd662e7a35983 100644 --- a/crates/uv-workspace/src/workspace.rs +++ b/crates/uv-workspace/src/workspace.rs @@ -873,6 +873,7 @@ impl ProjectWorkspace { // Only walk up the given directory, if any. options .stop_discovery_at + .and_then(Path::parent) .map(|stop_discovery_at| stop_discovery_at != *path) .unwrap_or(true) }) @@ -1074,6 +1075,7 @@ async fn find_workspace( // Only walk up the given directory, if any. options .stop_discovery_at + .and_then(Path::parent) .map(|stop_discovery_at| stop_discovery_at != *path) .unwrap_or(true) }) @@ -1166,6 +1168,7 @@ pub fn check_nested_workspaces(inner_workspace_root: &Path, options: &DiscoveryO // Only walk up the given directory, if any. options .stop_discovery_at + .and_then(Path::parent) .map(|stop_discovery_at| stop_discovery_at != *path) .unwrap_or(true) }) @@ -1332,6 +1335,7 @@ impl VirtualProject { // Only walk up the given directory, if any. options .stop_discovery_at + .and_then(Path::parent) .map(|stop_discovery_at| stop_discovery_at != *path) .unwrap_or(true) }) diff --git a/crates/uv/src/commands/build.rs b/crates/uv/src/commands/build.rs index 0ecabe614c00b..6504e3d688236 100644 --- a/crates/uv/src/commands/build.rs +++ b/crates/uv/src/commands/build.rs @@ -22,7 +22,8 @@ use uv_fs::Simplified; use uv_normalize::PackageName; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation, - PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionRequest, + PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions, + VersionRequest, }; use uv_requirements::RequirementsSource; use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython}; @@ -366,9 +367,12 @@ async fn build_package( // (2) Request from `.python-version` if interpreter_request.is_none() { - interpreter_request = PythonVersionFile::discover(source.directory(), no_config, false) - .await? - .and_then(PythonVersionFile::into_version); + interpreter_request = PythonVersionFile::discover( + source.directory(), + &VersionFileDiscoveryOptions::default().with_no_config(no_config), + ) + .await? + .and_then(PythonVersionFile::into_version); } // (3) `Requires-Python` in `pyproject.toml` diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index d40cc70917c11..b55c941e02c02 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -25,7 +25,7 @@ use uv_pep508::{ExtraName, Requirement, UnnamedRequirement, VersionOrUrl}; use uv_pypi_types::{redact_git_credentials, ParsedUrl, RequirementSource, VerbatimParsedUrl}; use uv_python::{ EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation, - PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionRequest, + PythonPreference, PythonRequest, }; use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification}; use uv_resolver::FlatIndex; @@ -41,7 +41,9 @@ use crate::commands::pip::loggers::{ }; use crate::commands::pip::operations::Modifications; use crate::commands::pip::resolution_environment; -use crate::commands::project::{script_python_requirement, ProjectError}; +use crate::commands::project::{ + init_script_python_requirement, validate_script_requires_python, ProjectError, ScriptPython, +}; use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter}; use crate::commands::{diagnostics, pip, project, ExitStatus, SharedState}; use crate::printer::Printer; @@ -128,7 +130,7 @@ pub(crate) async fn add( let script = if let Some(script) = Pep723Script::read(&script).await? { script } else { - let requires_python = script_python_requirement( + let requires_python = init_script_python_requirement( python.as_deref(), project_dir, false, @@ -142,28 +144,12 @@ pub(crate) async fn add( Pep723Script::init(&script, requires_python.specifiers()).await? }; - let python_request = if let Some(request) = python.as_deref() { - // (1) Explicit request from user - Some(PythonRequest::parse(request)) - } else if let Some(request) = PythonVersionFile::discover(project_dir, false, false) - .await? - .and_then(PythonVersionFile::into_version) - { - // (2) Request from `.python-version` - Some(request) - } else { - // (3) `Requires-Python` in `pyproject.toml` - script - .metadata - .requires_python - .clone() - .map(|requires_python| { - PythonRequest::Version(VersionRequest::Range( - requires_python, - PythonVariant::Default, - )) - }) - }; + let ScriptPython { + source, + python_request, + requires_python, + } = ScriptPython::from_request(python.as_deref().map(PythonRequest::parse), None, &script) + .await?; let interpreter = PythonInstallation::find_or_download( python_request.as_ref(), @@ -177,6 +163,16 @@ pub(crate) async fn add( .await? .into_interpreter(); + if let Some((requires_python, requires_python_source)) = requires_python { + validate_script_requires_python( + &interpreter, + None, + &requires_python, + &requires_python_source, + &source, + )?; + } + Target::Script(script, Box::new(interpreter)) } else { // Find the project in the workspace. diff --git a/crates/uv/src/commands/project/export.rs b/crates/uv/src/commands/project/export.rs index 0b0f92455d3f5..b840f783cd160 100644 --- a/crates/uv/src/commands/project/export.rs +++ b/crates/uv/src/commands/project/export.rs @@ -76,6 +76,7 @@ pub(crate) async fn export( // Find an interpreter for the project let interpreter = ProjectInterpreter::discover( project.workspace(), + project_dir, python.as_deref().map(PythonRequest::parse), python_preference, python_downloads, diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 79a4a8ffc0763..013703eaa75a0 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -13,7 +13,7 @@ use uv_pep440::Version; use uv_pep508::PackageName; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest, - PythonVariant, PythonVersionFile, VersionRequest, + PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions, VersionRequest, }; use uv_resolver::RequiresPython; use uv_scripts::{Pep723Script, ScriptTag}; @@ -21,7 +21,7 @@ use uv_warnings::warn_user_once; use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut}; use uv_workspace::{DiscoveryOptions, MemberDiscovery, Workspace, WorkspaceError}; -use crate::commands::project::{find_requires_python, script_python_requirement}; +use crate::commands::project::{find_requires_python, init_script_python_requirement}; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::ExitStatus; use crate::printer::Printer; @@ -207,7 +207,7 @@ async fn init_script( } }; - let requires_python = script_python_requirement( + let requires_python = init_script_python_requirement( python.as_deref(), &CWD, no_pin_python, @@ -659,7 +659,7 @@ impl InitProjectKind { // Write .python-version if it doesn't exist. if let Some(python_request) = python_request { - if PythonVersionFile::discover(path, false, false) + if PythonVersionFile::discover(path, &VersionFileDiscoveryOptions::default()) .await? .is_none() { @@ -724,7 +724,7 @@ impl InitProjectKind { // Write .python-version if it doesn't exist. if let Some(python_request) = python_request { - if PythonVersionFile::discover(path, false, false) + if PythonVersionFile::discover(path, &VersionFileDiscoveryOptions::default()) .await? .is_none() { diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index e6920d6b3bace..fc0353e336de3 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -88,6 +88,7 @@ pub(crate) async fn lock( // Find an interpreter for the project let interpreter = ProjectInterpreter::discover( &workspace, + project_dir, python.as_deref().map(PythonRequest::parse), python_preference, python_downloads, diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 699802b924160..6711ccc51e6f4 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -14,7 +14,7 @@ use uv_distribution::DistributionDatabase; use uv_distribution_types::{ Resolution, UnresolvedRequirement, UnresolvedRequirementSpecification, }; -use uv_fs::Simplified; +use uv_fs::{Simplified, CWD}; use uv_git::ResolvedRepositoryReference; use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::PackageName; @@ -24,7 +24,7 @@ use uv_pypi_types::Requirement; use uv_python::{ EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, - VersionRequest, + VersionFileDiscoveryOptions, VersionRequest, }; use uv_requirements::upgrade::{read_lock_requirements, LockedRequirements}; use uv_requirements::{ @@ -34,6 +34,7 @@ use uv_resolver::{ FlatIndex, Lock, OptionsBuilder, PythonRequirement, RequiresPython, ResolutionGraph, ResolverMarkers, }; +use uv_scripts::Pep723Script; use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::Workspace; @@ -81,13 +82,13 @@ pub(crate) enum ProjectError { RequiresPythonProjectIncompatibility(Version, RequiresPython), #[error("The requested interpreter resolved to Python {0}, which is incompatible with the script's Python requirement: `{1}`")] - RequestedPythonScriptIncompatibility(Version, VersionSpecifiers), + RequestedPythonScriptIncompatibility(Version, RequiresPython), #[error("The Python request from `{0}` resolved to Python {1}, which is incompatible with the script's Python requirement: `{2}`")] - DotPythonVersionScriptIncompatibility(String, Version, VersionSpecifiers), + DotPythonVersionScriptIncompatibility(String, Version, RequiresPython), #[error("The resolved Python interpreter (Python {0}) is incompatible with the script's Python requirement: `{1}`")] - RequiresPythonScriptIncompatibility(Version, VersionSpecifiers), + RequiresPythonScriptIncompatibility(Version, RequiresPython), #[error("The requested interpreter resolved to Python {0}, which is incompatible with the project's Python requirement: `{1}`. However, a workspace member (`{member}`) supports Python {3}. To install the workspace member on its own, navigate to `{path}`, then run `{venv}` followed by `{install}`.", member = _2.cyan(), venv = format!("uv venv --python {_0}").green(), install = "uv pip install -e .".green(), path = _4.user_display().cyan() )] RequestedMemberIncompatibility( @@ -204,7 +205,7 @@ pub(crate) fn find_requires_python( #[allow(clippy::result_large_err)] pub(crate) fn validate_requires_python( interpreter: &Interpreter, - workspace: &Workspace, + workspace: Option<&Workspace>, requires_python: &RequiresPython, source: &PythonRequestSource, ) -> Result<(), ProjectError> { @@ -217,7 +218,7 @@ pub(crate) fn validate_requires_python( // a library in the workspace is compatible with Python >=3.8, the user may attempt // to sync on Python 3.8. This will fail, but we should provide a more helpful error // message. - for (name, member) in workspace.packages() { + for (name, member) in workspace.into_iter().flat_map(Workspace::packages) { let Some(project) = member.pyproject_toml().project.as_ref() else { continue; }; @@ -237,7 +238,7 @@ pub(crate) fn validate_requires_python( } PythonRequestSource::DotPythonVersion(file) => { Err(ProjectError::DotPythonVersionMemberIncompatibility( - file.to_string(), + file.path().user_display().to_string(), interpreter.python_version().clone(), requires_python.clone(), name.clone(), @@ -267,7 +268,7 @@ pub(crate) fn validate_requires_python( } PythonRequestSource::DotPythonVersion(file) => { Err(ProjectError::DotPythonVersionProjectIncompatibility( - file.to_string(), + file.path().user_display().to_string(), interpreter.python_version().clone(), requires_python.clone(), )) @@ -281,6 +282,49 @@ pub(crate) fn validate_requires_python( } } +/// Returns an error if the [`Interpreter`] does not satisfy script or workspace `requires-python`. +#[allow(clippy::result_large_err)] +pub(crate) fn validate_script_requires_python( + interpreter: &Interpreter, + workspace: Option<&Workspace>, + requires_python: &RequiresPython, + requires_python_source: &RequiresPythonSource, + request_source: &PythonRequestSource, +) -> Result<(), ProjectError> { + match requires_python_source { + RequiresPythonSource::Project => { + validate_requires_python(interpreter, workspace, requires_python, request_source)?; + } + RequiresPythonSource::Script => {} + }; + + if requires_python.contains(interpreter.python_version()) { + return Ok(()); + } + + match request_source { + PythonRequestSource::UserRequest => { + Err(ProjectError::RequestedPythonScriptIncompatibility( + interpreter.python_version().clone(), + requires_python.clone(), + )) + } + PythonRequestSource::DotPythonVersion(file) => { + Err(ProjectError::DotPythonVersionScriptIncompatibility( + file.file_name().to_string(), + interpreter.python_version().clone(), + requires_python.clone(), + )) + } + PythonRequestSource::RequiresPython => { + Err(ProjectError::RequiresPythonScriptIncompatibility( + interpreter.python_version().clone(), + requires_python.clone(), + )) + } + } +} + /// An interpreter suitable for the project. #[derive(Debug)] #[allow(clippy::large_enum_variant)] @@ -296,43 +340,64 @@ pub(crate) enum PythonRequestSource { /// The request was provided by the user. UserRequest, /// The request was inferred from a `.python-version` or `.python-versions` file. - DotPythonVersion(String), + DotPythonVersion(PythonVersionFile), /// The request was inferred from a `pyproject.toml` file. RequiresPython, } +impl std::fmt::Display for PythonRequestSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PythonRequestSource::UserRequest => write!(f, "explicit request"), + PythonRequestSource::DotPythonVersion(file) => { + write!(f, "version file at `{}`", file.path().user_display()) + } + PythonRequestSource::RequiresPython => write!(f, "`requires-python` metadata"), + } + } +} + /// The resolved Python request and requirement for a [`Workspace`]. #[derive(Debug, Clone)] pub(crate) struct WorkspacePython { /// The source of the Python request. - source: PythonRequestSource, + pub(crate) source: PythonRequestSource, /// The resolved Python request, computed by considering (1) any explicit request from the user /// via `--python`, (2) any implicit request from the user via `.python-version`, and (3) any /// `Requires-Python` specifier in the `pyproject.toml`. - python_request: Option, + pub(crate) python_request: Option, /// The resolved Python requirement for the project, computed by taking the intersection of all /// `Requires-Python` specifiers in the workspace. - requires_python: Option, + pub(crate) requires_python: Option, } impl WorkspacePython { /// Determine the [`WorkspacePython`] for the current [`Workspace`]. pub(crate) async fn from_request( python_request: Option, - workspace: &Workspace, + workspace: Option<&Workspace>, + project_dir: &Path, ) -> Result { - let requires_python = find_requires_python(workspace)?; + let requires_python = workspace + .and_then(|workspace| find_requires_python(workspace).transpose()) + .transpose()?; + + let workspace_root = workspace.map(Workspace::install_path); let (source, python_request) = if let Some(request) = python_request { // (1) Explicit request from user let source = PythonRequestSource::UserRequest; let request = Some(request); (source, request) - } else if let Some(file) = - PythonVersionFile::discover(workspace.install_path(), false, false).await? + } else if let Some(file) = PythonVersionFile::discover( + project_dir, + &VersionFileDiscoveryOptions::default() + .with_stop_discovery_at(workspace_root.map(PathBuf::as_ref)), + ) + .await? { // (2) Request from `.python-version` - let source = PythonRequestSource::DotPythonVersion(file.file_name().to_string()); + let source = PythonRequestSource::DotPythonVersion(file.clone()); let request = file.into_version(); (source, request) } else { @@ -350,6 +415,84 @@ impl WorkspacePython { (source, request) }; + if let Some(python_request) = python_request.as_ref() { + debug!( + "Using Python request `{}` from {source}", + python_request.to_canonical_string() + ); + }; + + Ok(Self { + source, + python_request, + requires_python, + }) + } +} + +/// The source of a `Requires-Python` specifier. +#[derive(Debug, Clone)] +pub(crate) enum RequiresPythonSource { + /// From the PEP 723 inline script metadata. + Script, + /// From a `pyproject.toml` in a workspace. + Project, +} + +/// The resolved Python request and requirement for a [`Pep723Script`] +#[derive(Debug, Clone)] +pub(crate) struct ScriptPython { + /// The source of the Python request. + pub(crate) source: PythonRequestSource, + /// The resolved Python request, computed by considering (1) any explicit request from the user + /// via `--python`, (2) any implicit request from the user via `.python-version`, (3) any + /// `Requires-Python` specifier in the script metadata, and (4) any `Requires-Python` specifier + /// in the `pyproject.toml`. + pub(crate) python_request: Option, + /// The resolved Python requirement for the script and its source. + pub(crate) requires_python: Option<(RequiresPython, RequiresPythonSource)>, +} + +impl ScriptPython { + /// Determine the [`ScriptPython`] for the current [`Workspace`]. + pub(crate) async fn from_request( + python_request: Option, + workspace: Option<&Workspace>, + script: &Pep723Script, + ) -> Result { + // First, discover a requirement from the workspace + let WorkspacePython { + source, + mut python_request, + requires_python, + } = WorkspacePython::from_request( + python_request, + workspace, + script.path.parent().unwrap_or(&**CWD), + ) + .await?; + + let mut requires_python = + requires_python.map(|requirement| (requirement, RequiresPythonSource::Project)); + + // If the script has a `Requires-Python` specifier, prefer that over one from the workspace. + if matches!(source, PythonRequestSource::RequiresPython) { + if let Some(requires_python_specifiers) = script.metadata.requires_python.as_ref() { + python_request = Some(PythonRequest::Version(VersionRequest::Range( + requires_python_specifiers.clone(), + PythonVariant::Default, + ))); + requires_python = Some(( + RequiresPython::from_specifiers(requires_python_specifiers)?, + RequiresPythonSource::Script, + )); + } + } + + if let Some(python_request) = python_request.as_ref() { + debug!("Using Python request {python_request} from {source}"); + }; + Ok(Self { source, python_request, @@ -362,6 +505,7 @@ impl ProjectInterpreter { /// Discover the interpreter to use in the current [`Workspace`]. pub(crate) async fn discover( workspace: &Workspace, + project_dir: &Path, python_request: Option, python_preference: PythonPreference, python_downloads: PythonDownloads, @@ -375,7 +519,7 @@ impl ProjectInterpreter { source, python_request, requires_python, - } = WorkspacePython::from_request(python_request, workspace).await?; + } = WorkspacePython::from_request(python_request, Some(workspace), project_dir).await?; // Read from the virtual environment first. let venv = workspace.venv(); @@ -383,11 +527,15 @@ impl ProjectInterpreter { Ok(venv) => { if python_request.as_ref().map_or(true, |request| { if request.satisfied(venv.interpreter(), cache) { - debug!("The virtual environment's Python version satisfies `{request}`"); + debug!( + "The virtual environment's Python version satisfies `{}`", + request.to_canonical_string() + ); true } else { debug!( - "The virtual environment's Python version does not satisfy `{request}`" + "The virtual environment's Python version does not satisfy `{}`", + request.to_canonical_string() ); false } @@ -479,7 +627,7 @@ impl ProjectInterpreter { } if let Some(requires_python) = requires_python.as_ref() { - validate_requires_python(&interpreter, workspace, requires_python, &source)?; + validate_requires_python(&interpreter, Some(workspace), requires_python, &source)?; } Ok(Self::Interpreter(interpreter)) @@ -507,6 +655,7 @@ pub(crate) async fn get_or_init_environment( ) -> Result { match ProjectInterpreter::discover( workspace, + workspace.install_path().as_ref(), python, python_preference, python_downloads, @@ -1271,8 +1420,8 @@ pub(crate) async fn update_environment( }) } -/// Determine the [`RequiresPython`] requirement for a PEP 723 script. -pub(crate) async fn script_python_requirement( +/// Determine the [`RequiresPython`] requirement for a new PEP 723 script. +pub(crate) async fn init_script_python_requirement( python: Option<&str>, directory: &Path, no_pin_python: bool, @@ -1287,7 +1436,7 @@ pub(crate) async fn script_python_requirement( PythonRequest::parse(request) } else if let (false, Some(request)) = ( no_pin_python, - PythonVersionFile::discover(directory, false, false) + PythonVersionFile::discover(directory, &VersionFileDiscoveryOptions::default()) .await? .and_then(PythonVersionFile::into_version), ) { diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 5d7a839ad28fa..a8eec17fafab4 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -23,7 +23,7 @@ use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_python::{ EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation, - PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionRequest, + PythonPreference, PythonRequest, PythonVersionFile, VersionFileDiscoveryOptions, }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_resolver::Lock; @@ -38,8 +38,8 @@ use crate::commands::pip::operations; use crate::commands::pip::operations::Modifications; use crate::commands::project::environment::CachedEnvironment; use crate::commands::project::{ - validate_requires_python, EnvironmentSpecification, ProjectError, PythonRequestSource, - WorkspacePython, + validate_requires_python, validate_script_requires_python, EnvironmentSpecification, + ProjectError, ScriptPython, WorkspacePython, }; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::{diagnostics, project, ExitStatus, SharedState}; @@ -111,31 +111,12 @@ pub(crate) async fn run( script.path.user_display().cyan() )?; - let (source, python_request) = if let Some(request) = python.as_deref() { - // (1) Explicit request from user - let source = PythonRequestSource::UserRequest; - let request = Some(PythonRequest::parse(request)); - (source, request) - } else if let Some(file) = PythonVersionFile::discover(&project_dir, false, false).await? { - // (2) Request from `.python-version` - let source = PythonRequestSource::DotPythonVersion(file.file_name().to_string()); - let request = file.into_version(); - (source, request) - } else { - // (3) `Requires-Python` in the script - let request = script - .metadata - .requires_python - .as_ref() - .map(|requires_python| { - PythonRequest::Version(VersionRequest::Range( - requires_python.clone(), - PythonVariant::Default, - )) - }); - let source = PythonRequestSource::RequiresPython; - (source, request) - }; + let ScriptPython { + source, + python_request, + requires_python, + } = ScriptPython::from_request(python.as_deref().map(PythonRequest::parse), None, &script) + .await?; let client_builder = BaseClientBuilder::new() .connectivity(connectivity) @@ -153,30 +134,18 @@ pub(crate) async fn run( .await? .into_interpreter(); - if let Some(requires_python) = script.metadata.requires_python.as_ref() { - if !requires_python.contains(interpreter.python_version()) { - let err = match source { - PythonRequestSource::UserRequest => { - ProjectError::RequestedPythonScriptIncompatibility( - interpreter.python_version().clone(), - requires_python.clone(), - ) - } - PythonRequestSource::DotPythonVersion(file) => { - ProjectError::DotPythonVersionScriptIncompatibility( - file, - interpreter.python_version().clone(), - requires_python.clone(), - ) - } - PythonRequestSource::RequiresPython => { - ProjectError::RequiresPythonScriptIncompatibility( - interpreter.python_version().clone(), - requires_python.clone(), - ) - } - }; - warn_user!("{err}"); + if let Some((requires_python, requires_python_source)) = requires_python { + match validate_script_requires_python( + &interpreter, + None, + &requires_python, + &requires_python_source, + &source, + ) { + Ok(()) => {} + Err(err) => { + warn_user!("{err}"); + } } } @@ -430,7 +399,8 @@ pub(crate) async fn run( requires_python, } = WorkspacePython::from_request( python.as_deref().map(PythonRequest::parse), - project.workspace(), + Some(project.workspace()), + project_dir, ) .await?; @@ -449,7 +419,7 @@ pub(crate) async fn run( if let Some(requires_python) = requires_python.as_ref() { validate_requires_python( &interpreter, - project.workspace(), + Some(project.workspace()), requires_python, &source, )?; @@ -578,9 +548,12 @@ pub(crate) async fn run( Some(PythonRequest::parse(request)) // (2) Request from `.python-version` } else { - PythonVersionFile::discover(&project_dir, no_config, false) - .await? - .and_then(PythonVersionFile::into_version) + PythonVersionFile::discover( + &project_dir, + &VersionFileDiscoveryOptions::default().with_no_config(no_config), + ) + .await? + .and_then(PythonVersionFile::into_version) }; let python = PythonInstallation::find_or_download( diff --git a/crates/uv/src/commands/project/tree.rs b/crates/uv/src/commands/project/tree.rs index 0ddbec93fdbda..9bb89ee33d4ff 100644 --- a/crates/uv/src/commands/project/tree.rs +++ b/crates/uv/src/commands/project/tree.rs @@ -47,6 +47,7 @@ pub(crate) async fn tree( // Find an interpreter for the project let interpreter = ProjectInterpreter::discover( &workspace, + project_dir, python.as_deref().map(PythonRequest::parse), python_preference, python_downloads, diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index 2b3da29efaa0c..e4f98990bbd9c 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -4,22 +4,21 @@ use std::path::Path; use uv_cache::Cache; use uv_fs::Simplified; -use uv_python::{ - EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest, PythonVariant, - PythonVersionFile, VersionRequest, -}; -use uv_resolver::RequiresPython; -use uv_warnings::warn_user_once; +use uv_python::{EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest}; +use uv_warnings::{warn_user, warn_user_once}; use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError}; -use crate::commands::{project::find_requires_python, ExitStatus}; +use crate::commands::{ + project::{validate_requires_python, WorkspacePython}, + ExitStatus, +}; /// Find a Python interpreter. pub(crate) async fn find( project_dir: &Path, request: Option, no_project: bool, - no_config: bool, + _no_config: bool, system: bool, python_preference: PythonPreference, cache: &Cache, @@ -30,50 +29,54 @@ pub(crate) async fn find( EnvironmentPreference::Any }; - // (1) Explicit request from user - let mut request = request.map(|request| PythonRequest::parse(&request)); - - // (2) Request from `.python-version` - if request.is_none() { - request = PythonVersionFile::discover(project_dir, no_config, false) - .await? - .and_then(PythonVersionFile::into_version); - } - - // (3) `Requires-Python` in `pyproject.toml` - if request.is_none() && !no_project { - let project = - match VirtualProject::discover(project_dir, &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 { - request = find_requires_python(project.workspace())? - .as_ref() - .map(RequiresPython::specifiers) - .map(|specifiers| { - PythonRequest::Version(VersionRequest::Range( - specifiers.clone(), - PythonVariant::Default, - )) - }); + let project = if no_project { + None + } else { + match VirtualProject::discover(project_dir, &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 + } } - } + }; + + let WorkspacePython { + source, + python_request, + requires_python, + } = WorkspacePython::from_request( + request.map(|request| PythonRequest::parse(&request)), + project.as_ref().map(VirtualProject::workspace), + project_dir, + ) + .await?; let python = PythonInstallation::find( - &request.unwrap_or_default(), + &python_request.unwrap_or_default(), environment_preference, python_preference, cache, )?; + // Warn if the discovered Python version is incompatible with the current workspace + if let Some(requires_python) = requires_python { + match validate_requires_python( + python.interpreter(), + project.as_ref().map(VirtualProject::workspace), + &requires_python, + &source, + ) { + Ok(()) => {} + Err(err) => { + warn_user!("{err}"); + } + } + }; + println!( "{}", std::path::absolute(python.interpreter().sys_executable())?.simplified_display() diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index ac373eafd7c15..9d76bb24a82f8 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -11,7 +11,10 @@ use std::path::Path; use uv_client::Connectivity; use uv_python::downloads::{DownloadResult, ManagedPythonDownload, PythonDownloadRequest}; use uv_python::managed::{ManagedPythonInstallation, ManagedPythonInstallations}; -use uv_python::{PythonDownloads, PythonRequest, PythonVersionFile}; +use uv_python::{ + PythonDownloads, PythonRequest, PythonVersionFile, VersionFileDiscoveryOptions, + VersionFilePreference, +}; use crate::commands::python::{ChangeEvent, ChangeEventKind}; use crate::commands::reporters::PythonDownloadReporter; @@ -38,10 +41,15 @@ pub(crate) async fn install( let targets = targets.into_iter().collect::>(); let requests: Vec<_> = if targets.is_empty() { - PythonVersionFile::discover(project_dir, no_config, true) - .await? - .map(PythonVersionFile::into_versions) - .unwrap_or_else(|| vec![PythonRequest::Default]) + PythonVersionFile::discover( + project_dir, + &VersionFileDiscoveryOptions::default() + .with_no_config(no_config) + .with_preference(VersionFilePreference::Versions), + ) + .await? + .map(PythonVersionFile::into_versions) + .unwrap_or_else(|| vec![PythonRequest::Default]) } else { targets .iter() diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index 2e03dd5e5c121..75e24cced59d0 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -10,7 +10,7 @@ use uv_cache::Cache; use uv_fs::Simplified; use uv_python::{ EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile, - PYTHON_VERSION_FILENAME, + VersionFileDiscoveryOptions, PYTHON_VERSION_FILENAME, }; use uv_warnings::warn_user_once; use uv_workspace::{DiscoveryOptions, VirtualProject}; @@ -40,7 +40,8 @@ pub(crate) async fn pin( } }; - let version_file = PythonVersionFile::discover(project_dir, false, false).await; + let version_file = + PythonVersionFile::discover(project_dir, &VersionFileDiscoveryOptions::default()).await; let Some(request) = request else { // Display the current pinned Python version diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 425c0b5a343c3..1a107557e5dfb 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -23,9 +23,8 @@ use uv_install_wheel::linker::LinkMode; use uv_pypi_types::Requirement; use uv_python::{ EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest, - PythonVariant, PythonVersionFile, VersionRequest, }; -use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython}; +use uv_resolver::{ExcludeNewer, FlatIndex}; use uv_shell::Shell; use uv_types::{BuildContext, BuildIsolation, HashStrategy}; use uv_warnings::warn_user_once; @@ -33,7 +32,7 @@ use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError}; use crate::commands::pip::loggers::{DefaultInstallLogger, InstallLogger}; use crate::commands::pip::operations::Changelog; -use crate::commands::project::find_requires_python; +use crate::commands::project::{validate_requires_python, WorkspacePython}; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::{ExitStatus, SharedState}; use crate::printer::Printer; @@ -143,7 +142,7 @@ async fn venv_impl( exclude_newer: Option, concurrency: Concurrency, native_tls: bool, - no_config: bool, + _no_config: bool, no_project: bool, cache: &Cache, printer: Printer, @@ -184,36 +183,21 @@ async fn venv_impl( let reporter = PythonDownloadReporter::single(printer); - // (1) Explicit request from user - let mut interpreter_request = python_request.map(PythonRequest::parse); - - // (2) Request from `.python-version` - if interpreter_request.is_none() { - interpreter_request = PythonVersionFile::discover(project_dir, no_config, false) - .await - .into_diagnostic()? - .and_then(PythonVersionFile::into_version); - } - - // (3) `Requires-Python` in `pyproject.toml` - if interpreter_request.is_none() { - if let Some(project) = project { - interpreter_request = find_requires_python(project.workspace()) - .into_diagnostic()? - .as_ref() - .map(RequiresPython::specifiers) - .map(|specifiers| { - PythonRequest::Version(VersionRequest::Range( - specifiers.clone(), - PythonVariant::Default, - )) - }); - } - } + let WorkspacePython { + source, + python_request, + requires_python, + } = WorkspacePython::from_request( + python_request.map(PythonRequest::parse), + project.as_ref().map(VirtualProject::workspace), + project_dir, + ) + .await + .into_diagnostic()?; // Locate the Python interpreter to use in the environment let python = PythonInstallation::find_or_download( - interpreter_request.as_ref(), + python_request.as_ref(), EnvironmentPreference::OnlySystem, python_preference, python_downloads, @@ -224,6 +208,17 @@ async fn venv_impl( .await .into_diagnostic()?; + // Check if the discovered Python version is incompatible with the current workspace + if let Some(requires_python) = requires_python { + validate_requires_python( + python.interpreter(), + project.as_ref().map(VirtualProject::workspace), + &requires_python, + &source, + ) + .into_diagnostic()?; + }; + let managed = python.source().is_managed(); let implementation = python.implementation(); let interpreter = python.into_interpreter(); diff --git a/crates/uv/tests/python_find.rs b/crates/uv/tests/python_find.rs index da8c27ccfa1aa..ad3ad7108e1c7 100644 --- a/crates/uv/tests/python_find.rs +++ b/crates/uv/tests/python_find.rs @@ -193,6 +193,38 @@ fn python_find_pin() { success: true exit_code: 0 ----- stdout ----- + [PYTHON-3.12] + + ----- stderr ----- + "###); + + let child_dir = context.temp_dir.child("child"); + child_dir.create_dir_all().unwrap(); + + // We should also find pinned versions in the parent directory + uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.12] + + ----- stderr ----- + "###); + + uv_snapshot!(context.filters(), context.python_pin().arg("3.11").current_dir(&child_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Updated `.python-version` from `3.12` -> `3.11` + + ----- stderr ----- + "###); + + // Unless the child directory also has a pin + uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- [PYTHON-3.11] ----- stderr ----- @@ -201,7 +233,7 @@ fn python_find_pin() { #[test] fn python_find_project() { - let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]); + let context: TestContext = TestContext::new_with_versions(&["3.10", "3.11", "3.12"]); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml @@ -209,7 +241,7 @@ fn python_find_project() { [project] name = "project" version = "0.1.0" - requires-python = ">=3.12" + requires-python = ">=3.11" dependencies = ["anyio==3.7.0"] "#}) .unwrap(); @@ -219,19 +251,20 @@ fn python_find_project() { success: true exit_code: 0 ----- stdout ----- - [PYTHON-3.12] + [PYTHON-3.11] ----- stderr ----- "###); // Unless explicitly requested - uv_snapshot!(context.filters(), context.python_find().arg("3.11"), @r###" + uv_snapshot!(context.filters(), context.python_find().arg("3.10"), @r###" success: true exit_code: 0 ----- stdout ----- - [PYTHON-3.11] + [PYTHON-3.10] ----- stderr ----- + warning: The requested interpreter resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11` "###); // Or `--no-project` is used @@ -239,6 +272,69 @@ fn python_find_project() { success: true exit_code: 0 ----- stdout ----- + [PYTHON-3.10] + + ----- stderr ----- + "###); + + // But a pin should take precedence + uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Pinned `.python-version` to `3.12` + + ----- stderr ----- + "###); + uv_snapshot!(context.filters(), context.python_find(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.12] + + ----- stderr ----- + "###); + + // Create a pin that's incompatible with the project + uv_snapshot!(context.filters(), context.python_pin().arg("3.10").arg("--no-workspace"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Updated `.python-version` from `3.12` -> `3.10` + + ----- stderr ----- + "###); + + // We should warn on subsequent uses, but respect the pinned version? + uv_snapshot!(context.filters(), context.python_find(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.10] + + ----- stderr ----- + warning: The Python request from `.python-version` resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11` + "###); + + // Unless the pin file is outside the project, in which case we should just ignore it + let child_dir = context.temp_dir.child("child"); + child_dir.create_dir_all().unwrap(); + + let pyproject_toml = child_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.11" + dependencies = ["anyio==3.7.0"] + "#}) + .unwrap(); + + uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- [PYTHON-3.11] ----- stderr ----- diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index dd9751e2a8a54..30544ebcc1c92 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -420,7 +420,6 @@ fn run_pep723_script_requires_python() -> Result<()> { ----- stderr ----- Reading inline script metadata from: main.py - warning: The Python request from `.python-version` resolved to Python 3.8.[X], which is incompatible with the script's Python requirement: `>=3.11` Resolved 1 package in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] diff --git a/crates/uv/tests/sync.rs b/crates/uv/tests/sync.rs index d5076da029115..d591711b2d204 100644 --- a/crates/uv/tests/sync.rs +++ b/crates/uv/tests/sync.rs @@ -1753,14 +1753,12 @@ fn sync_custom_environment_path() -> Result<()> { // But if it's just an incompatible virtual environment... fs_err::remove_dir_all(context.temp_dir.join("foo"))?; uv_snapshot!(context.filters(), context.venv().arg("foo").arg("--python").arg("3.11"), @r###" - success: true - exit_code: 0 + success: false + exit_code: 1 ----- stdout ----- ----- stderr ----- - Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] - Creating virtual environment at: foo - Activate with: source foo/[BIN]/activate + × The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` "###); // Even with some extraneous content... @@ -2645,14 +2643,12 @@ fn sync_invalid_environment() -> Result<()> { // But if it's just an incompatible virtual environment... fs_err::remove_dir_all(context.temp_dir.join(".venv"))?; uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r###" - success: true - exit_code: 0 + success: false + exit_code: 1 ----- stdout ----- ----- stderr ----- - Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] - Creating virtual environment at: .venv - Activate with: source .venv/[BIN]/activate + × The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` "###); // Even with some extraneous content... @@ -2802,3 +2798,124 @@ fn sync_no_sources_missing_member() -> Result<()> { Ok(()) } + +#[test] +fn sync_python_version() -> Result<()> { + let context: TestContext = TestContext::new_with_versions(&["3.10", "3.11", "3.12"]); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc::indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.11" + dependencies = ["anyio==3.7.0"] + "#})?; + + // We should respect the project's required version, not the first on the path + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtual environment at: .venv + Resolved 4 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==3.7.0 + + idna==3.6 + + sniffio==1.3.1 + "###); + + // Unless explicitly requested... + uv_snapshot!(context.filters(), context.sync().arg("--python").arg("3.10"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.10.[X] interpreter at: [PYTHON-3.10] + error: The requested interpreter resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11` + "###); + + // But a pin should take precedence + uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Pinned `.python-version` to `3.12` + + ----- stderr ----- + "###); + + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Removed virtual environment at: .venv + Creating virtual environment at: .venv + Resolved 4 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==3.7.0 + + idna==3.6 + + sniffio==1.3.1 + "###); + + // Create a pin that's incompatible with the project + uv_snapshot!(context.filters(), context.python_pin().arg("3.10").arg("--no-workspace"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Updated `.python-version` from `3.12` -> `3.10` + + ----- stderr ----- + "###); + + // We should warn on subsequent uses, but respect the pinned version? + uv_snapshot!(context.filters(), context.sync(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.10.[X] interpreter at: [PYTHON-3.10] + error: The Python request from `.python-version` resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11` + "###); + + // Unless the pin file is outside the project, in which case we should just ignore it entirely + let child_dir = context.temp_dir.child("child"); + child_dir.create_dir_all().unwrap(); + + let pyproject_toml = child_dir.child("pyproject.toml"); + pyproject_toml + .write_str(indoc::indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.11" + dependencies = ["anyio==3.7.0"] + "#}) + .unwrap(); + + uv_snapshot!(context.filters(), context.sync().current_dir(&child_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtual environment at: .venv + Resolved 4 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==3.7.0 + + idna==3.6 + + sniffio==1.3.1 + "###); + + Ok(()) +} diff --git a/crates/uv/tests/venv.rs b/crates/uv/tests/venv.rs index 164a5bdd8496b..b6494ecd564c9 100644 --- a/crates/uv/tests/venv.rs +++ b/crates/uv/tests/venv.rs @@ -455,6 +455,17 @@ fn create_venv_respects_pyproject_requires_python() -> Result<()> { context.venv.assert(predicates::path::is_dir()); + // We warn if we receive an incompatible version + uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12` + "### + ); + Ok(()) }