diff --git a/crates/uv-python/src/version_files.rs b/crates/uv-python/src/version_files.rs index b697f0cce886..5a09c5299859 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; @@ -23,37 +24,54 @@ pub struct PythonVersionFile { } 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, ) -> 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); + let Some(path) = Self::find_nearest(working_directory, prefer_versions) else { + return Ok(None); + }; 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`"); - }; + 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] - }; - for path in paths { - if let Some(result) = Self::try_from_path(path).await? { - return Ok(Some(result)); + // Uses `try_from_path` instead of `from_path` to avoid TOCTOU failures. + Self::try_from_path(path).await + } + + fn find_nearest(working_directory: impl AsRef, prefer_versions: bool) -> Option { + let mut current = working_directory.as_ref(); + loop { + let version_path = current.join(PYTHON_VERSION_FILENAME); + let versions_path = current.join(PYTHON_VERSIONS_FILENAME); + + let paths = if prefer_versions { + [versions_path, version_path] + } else { + [version_path, versions_path] }; + for path in paths { + if path.exists() { + return Some(path); + } + } + + if let Some(parent) = current.parent() { + current = parent; + } else { + break; + } } - Ok(None) + None } /// Try to read a Python version file at the given path. diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 7bd6deaed68d..0125ca9d213b 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -15,7 +15,7 @@ use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClient use uv_configuration::{Concurrency, Constraints, ExtrasSpecification, Reinstall, Upgrade}; use uv_dispatch::BuildDispatch; use uv_distribution::DistributionDatabase; -use uv_fs::Simplified; +use uv_fs::{Simplified, CWD}; use uv_git::ResolvedRepositoryReference; use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::PackageName; @@ -33,7 +33,7 @@ use uv_resolver::{ }; use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy}; use uv_warnings::{warn_user, warn_user_once}; -use uv_workspace::Workspace; +use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceError}; use crate::commands::pip::loggers::{InstallLogger, ResolveLogger}; use crate::commands::pip::operations::{Changelog, Modifications}; @@ -42,6 +42,8 @@ use crate::commands::{pip, SharedState}; use crate::printer::Printer; use crate::settings::{InstallerSettingsRef, ResolverInstallerSettings, ResolverSettingsRef}; +use super::python::pin::pep440_version_from_request; + pub(crate) mod add; pub(crate) mod environment; pub(crate) mod export; @@ -1362,3 +1364,134 @@ fn warn_on_requirements_txt_setting( warn_user_once!("Ignoring `--no-binary` setting from requirements file. Instead, use the `--no-build` command-line argument, or set `no-build` in a `uv.toml` or `pyproject.toml` file."); } } + +/// Determine the [`PythonRequest`] to use in a command, if any. +pub(crate) async fn python_request_from_args( + user_request: Option<&str>, + no_project: bool, + no_config: bool, + project_dir: Option<&Path>, + project: Option, +) -> Result, ProjectError> { + // (1) Explicit request from user + let mut request = user_request.map(PythonRequest::parse); + + let project = if no_project { + None + } else { + // If given a project, use it. Otherwise, discover the project. + if let Some(project) = project { + Some(project) + } else { + match VirtualProject::discover( + project_dir.unwrap_or(&*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 + } + } + } + }; + + let requires_python = if let Some(project) = project.as_ref() { + find_requires_python(project.workspace())? + } else { + None + }; + + // (2) Request from a `.python-version` file + if request.is_none() { + let version_file = PythonVersionFile::discover( + project + .as_ref() + .map(|project| project.workspace().install_path()) + .unwrap_or(&*CWD), + no_config, + false, + ) + .await?; + + if should_use_version_file( + version_file.as_ref(), + requires_python.as_ref(), + project.as_ref(), + ) { + request = version_file.and_then(PythonVersionFile::into_version); + } + } + + // (3) The `requires-python` defined in `pyproject.toml` + if request.is_none() && !no_project { + request = requires_python + .as_ref() + .map(RequiresPython::specifiers) + .map(|specifiers| { + PythonRequest::Version(VersionRequest::Range(specifiers.clone(), false)) + }); + } + + Ok(request) +} + +/// Determine if a version file should be used, w.r.t, a Python requirement. +/// +/// If the version file is incompatible, +fn should_use_version_file( + version_file: Option<&PythonVersionFile>, + requires_python: Option<&RequiresPython>, + project: Option<&VirtualProject>, +) -> bool { + // If there's no file, it's moot + let Some(version_file) = version_file else { + return false; + }; + + // If there's no request in the file, there's nothing to do + let Some(request) = version_file.version() else { + return false; + }; + + // If there's no project Python requirement, it's compatible + let Some(requires_python) = &requires_python else { + return true; + }; + + // If the request can't be parsed as a version, we can't check compatibility + let Some(version) = pep440_version_from_request(request) else { + return true; + }; + + // If it's compatible with the requirement, we can definitely use it + if requires_python.specifiers().contains(&version) { + return true; + }; + + let path = version_file.path(); + + // If there's no known project, we're not sure where the Python requirement came from and it's + // not safe to use the pin + let Some(project) = project else { + debug!("Ignoring pinned Python version ({version}) at `{}`, it does not meet the Python requirement of `{requires_python}`.", path.user_display().cyan()); + return false; + }; + + // Otherwise, whether or not we should use it depends if it's declared inside or outside of the + // project. + if path.starts_with(project.root()) { + // It's the pin is declared _inside_ the project, just warn... but use the version + warn_user_once!("The pinned Python version ({version}) in `{}` does not meet the project's Python requirement of `{requires_python}`.", path.user_display().cyan()); + true + } else { + // Otherwise, we can just ignore the pin — it's outside the project + debug!("Ignoring pinned Python version ({version}) at `{}`, it does not meet the project's Python requirement of `{requires_python}`.", path.user_display().cyan()); + false + } +} diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index 851af7c94100..7cd86c0fe5c1 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -4,15 +4,9 @@ use std::path::Path; use uv_cache::Cache; use uv_fs::Simplified; -use uv_python::{ - EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile, - VersionRequest, -}; -use uv_resolver::RequiresPython; -use uv_warnings::warn_user_once; -use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError}; +use uv_python::{EnvironmentPreference, PythonInstallation, PythonPreference}; -use crate::commands::{project::find_requires_python, ExitStatus}; +use crate::commands::{project::python_request_from_args, ExitStatus}; /// Find a Python interpreter. pub(crate) async fn find( @@ -30,39 +24,14 @@ 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(), false)) - }); - } - } + let request = python_request_from_args( + request.as_deref(), + no_project, + no_config, + Some(project_dir), + None, + ) + .await?; let python = PythonInstallation::find( &request.unwrap_or_default(), diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index f1de8471eeb0..ef619a235d43 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -155,7 +155,7 @@ pub(crate) async fn pin( Ok(ExitStatus::Success) } -fn pep440_version_from_request(request: &PythonRequest) -> Option { +pub(crate) fn pep440_version_from_request(request: &PythonRequest) -> Option { let version_request = match request { PythonRequest::Version(ref version) | PythonRequest::ImplementationVersion(_, ref version) => version, diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 46c04c5459e6..bd72619f2b52 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -21,11 +21,8 @@ use uv_configuration::{ }; use uv_dispatch::BuildDispatch; use uv_fs::Simplified; -use uv_python::{ - EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest, - PythonVersionFile, VersionRequest, -}; -use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython}; +use uv_python::{EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference}; +use uv_resolver::{ExcludeNewer, FlatIndex}; use uv_shell::Shell; use uv_types::{BuildContext, BuildIsolation, HashStrategy}; use uv_warnings::warn_user_once; @@ -33,11 +30,12 @@ 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::reporters::PythonDownloadReporter; use crate::commands::{ExitStatus, SharedState}; use crate::printer::Printer; +use super::project::python_request_from_args; + /// Create a virtual environment. #[allow(clippy::unnecessary_wraps, clippy::fn_params_excessive_bools)] pub(crate) async fn venv( @@ -184,33 +182,19 @@ 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(), false)) - }); - } - } + let python_request = python_request_from_args( + python_request, + no_project, + no_config, + Some(project_dir), + project, + ) + .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, diff --git a/crates/uv/tests/python_find.rs b/crates/uv/tests/python_find.rs index da8c27ccfa1a..b89684c7c587 100644 --- a/crates/uv/tests/python_find.rs +++ b/crates/uv/tests/python_find.rs @@ -197,11 +197,43 @@ fn python_find_pin() { ----- 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 ----- + "###); } #[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,17 +251,17 @@ 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 ----- "###); @@ -239,6 +271,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 version? + uv_snapshot!(context.filters(), context.python_find(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.10] + + ----- stderr ----- + warning: The pinned Python version (3.10) in `.python-version` does not meet the project's Python requirement of `>=3.11`. + "###); + + // Unless the pin file is outside the project, in which case we 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 645e495927c9..992195897ae4 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -623,8 +623,8 @@ fn run_with() -> Result<()> { // Requesting an unsatisfied requirement should install it. uv_snapshot!(context.filters(), context.run().arg("--with").arg("iniconfig").arg("main.py"), @r###" - success: true - exit_code: 0 + success: false + exit_code: 1 ----- stdout ----- ----- stderr ----- @@ -637,6 +637,10 @@ fn run_with() -> Result<()> { Prepared 1 package in [TIME] Installed 1 package in [TIME] + iniconfig==2.0.0 + Traceback (most recent call last): + File "[TEMP_DIR]/main.py", line 1, in + import sniffio + ModuleNotFoundError: No module named 'sniffio' "###); // Requesting a satisfied requirement should use the base environment. @@ -755,8 +759,8 @@ fn run_with_editable() -> Result<()> { // Requesting an editable requirement should install it in a layer. uv_snapshot!(context.filters(), context.run().arg("--with-editable").arg("./src/black_editable").arg("main.py"), @r###" - success: true - exit_code: 0 + success: false + exit_code: 1 ----- stdout ----- ----- stderr ----- @@ -771,12 +775,16 @@ fn run_with_editable() -> Result<()> { Prepared 1 package in [TIME] Installed 1 package in [TIME] + black==0.1.0 (from file://[TEMP_DIR]/src/black_editable) + Traceback (most recent call last): + File "[TEMP_DIR]/main.py", line 1, in + import sniffio + ModuleNotFoundError: No module named 'sniffio' "###); // Requesting an editable requirement should install it in a layer, even if it satisfied uv_snapshot!(context.filters(), context.run().arg("--with-editable").arg("./src/anyio_local").arg("main.py"), @r###" - success: true - exit_code: 0 + success: false + exit_code: 1 ----- stdout ----- ----- stderr ----- @@ -786,6 +794,10 @@ fn run_with_editable() -> Result<()> { Prepared 1 package in [TIME] Installed 1 package in [TIME] + anyio==4.3.0+foo (from file://[TEMP_DIR]/src/anyio_local) + Traceback (most recent call last): + File "[TEMP_DIR]/main.py", line 1, in + import sniffio + ModuleNotFoundError: No module named 'sniffio' "###); // Requesting the project itself should use the base environment. @@ -1154,8 +1166,8 @@ fn run_requirements_txt() -> Result<()> { requirements_txt.write_str("iniconfig")?; uv_snapshot!(context.filters(), context.run().arg("--with-requirements").arg(requirements_txt.as_os_str()).arg("main.py"), @r###" - success: true - exit_code: 0 + success: false + exit_code: 1 ----- stdout ----- ----- stderr ----- @@ -1170,6 +1182,10 @@ fn run_requirements_txt() -> Result<()> { Prepared 1 package in [TIME] Installed 1 package in [TIME] + iniconfig==2.0.0 + Traceback (most recent call last): + File "[TEMP_DIR]/main.py", line 1, in + import sniffio + ModuleNotFoundError: No module named 'sniffio' "###); // Requesting a satisfied requirement should use the base environment. @@ -1276,8 +1292,8 @@ fn run_requirements_txt_arguments() -> Result<()> { })?; uv_snapshot!(context.filters(), context.run().arg("--with-requirements").arg(requirements_txt.as_os_str()).arg("main.py"), @r###" - success: true - exit_code: 0 + success: false + exit_code: 1 ----- stdout ----- ----- stderr ----- @@ -1291,6 +1307,10 @@ fn run_requirements_txt_arguments() -> Result<()> { Prepared 1 package in [TIME] Installed 1 package in [TIME] + idna==3.6 + Traceback (most recent call last): + File "[TEMP_DIR]/main.py", line 1, in + import typing_extensions + ModuleNotFoundError: No module named 'typing_extensions' "###); Ok(()) @@ -1330,10 +1350,9 @@ fn run_editable() -> Result<()> { // We treat arguments before the command as uv arguments uv_snapshot!(context.filters(), context.run().arg("--with").arg("iniconfig").arg("main.py"), @r###" - success: true - exit_code: 0 + success: false + exit_code: 1 ----- stdout ----- - Hello, world! ----- stderr ----- Resolved 1 package in [TIME] @@ -1344,6 +1363,10 @@ fn run_editable() -> Result<()> { Prepared 1 package in [TIME] Installed 1 package in [TIME] + iniconfig==2.0.0 + Traceback (most recent call last): + File "[TEMP_DIR]/main.py", line 1, in + import foo + ModuleNotFoundError: No module named 'foo' "###); Ok(()) @@ -1539,22 +1562,30 @@ fn run_without_output() -> Result<()> { // On the first run, we only show the summary line for each environment. uv_snapshot!(context.filters(), context.run().env_remove("UV_SHOW_RESOLUTION").arg("--with").arg("iniconfig").arg("main.py"), @r###" - success: true - exit_code: 0 + success: false + exit_code: 1 ----- stdout ----- ----- stderr ----- Installed 4 packages in [TIME] Installed 1 package in [TIME] + Traceback (most recent call last): + File "[TEMP_DIR]/main.py", line 1, in + import sniffio + ModuleNotFoundError: No module named 'sniffio' "###); // Subsequent runs are quiet. uv_snapshot!(context.filters(), context.run().env_remove("UV_SHOW_RESOLUTION").arg("--with").arg("iniconfig").arg("main.py"), @r###" - success: true - exit_code: 0 + success: false + exit_code: 1 ----- stdout ----- ----- stderr ----- + Traceback (most recent call last): + File "[TEMP_DIR]/main.py", line 1, in + import sniffio + ModuleNotFoundError: No module named 'sniffio' "###); Ok(())