Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve interactions between .python-version files and project requires-python #6372

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 35 additions & 17 deletions crates/uv-python/src/version_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<Path>,
// TODO(zanieb): Create a `DiscoverySettings` struct for these options
no_config: bool,
prefer_versions: bool,
) -> Result<Option<Self>, 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<Path>, prefer_versions: bool) -> Option<PathBuf> {
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.
Expand Down
137 changes: 135 additions & 2 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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};
Expand All @@ -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;
Expand Down Expand Up @@ -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<VirtualProject>,
) -> Result<Option<PythonRequest>, 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
}
}
51 changes: 10 additions & 41 deletions crates/uv/src/commands/python/find.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion crates/uv/src/commands/python/pin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ pub(crate) async fn pin(
Ok(ExitStatus::Success)
}

fn pep440_version_from_request(request: &PythonRequest) -> Option<pep440_rs::Version> {
pub(crate) fn pep440_version_from_request(request: &PythonRequest) -> Option<pep440_rs::Version> {
let version_request = match request {
PythonRequest::Version(ref version)
| PythonRequest::ImplementationVersion(_, ref version) => version,
Expand Down
44 changes: 14 additions & 30 deletions crates/uv/src/commands/venv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,21 @@ 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;
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(
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading