Skip to content

Commit

Permalink
Improve interactions between .python-version files and project `req…
Browse files Browse the repository at this point in the history
…uires-python`
  • Loading branch information
zanieb committed Aug 21, 2024
1 parent df3693e commit 5b48cf1
Show file tree
Hide file tree
Showing 4 changed files with 147 additions and 50 deletions.
78 changes: 76 additions & 2 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClient
use uv_configuration::{Concurrency, ExtrasSpecification, Reinstall, Upgrade};
use uv_dispatch::BuildDispatch;
use uv_distribution::DistributionDatabase;
use uv_fs::Simplified;
use uv_fs::{Simplified, CWD};
use uv_installer::{SatisfiesResult, SitePackages};
use uv_normalize::PackageName;
use uv_python::{
Expand All @@ -27,7 +27,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 @@ -36,6 +36,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 init;
Expand Down Expand Up @@ -1047,3 +1049,75 @@ 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 find_python_request(
user_request: Option<String>,
no_project: bool,
no_config: bool,
) -> Result<Option<PythonRequest>, ProjectError> {
// (1) Explicit request from user
let mut request = user_request.map(|request| PythonRequest::parse(&request));

let (project, requires_python) = if no_project {
(None, None)
} else {
let project = match VirtualProject::discover(&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
};

(project, requires_python)
};

// (2) Request from a `.python-version` file
if request.is_none() {
let version_file = PythonVersionFile::discover(&*CWD, no_config).await?;

let mut should_use = true;
if let Some(request) = version_file.as_ref().and_then(PythonVersionFile::version) {
// If we're in a project, make sure the request is compatible
if let Some(requires_python) = &requires_python {
if let Some(version) = pep440_version_from_request(request) {
if !requires_python.specifiers().contains(&version) {
let path = version_file.as_ref().unwrap().path();
if path.starts_with(project.unwrap().root()) {
// It's the pin is declared _inside_ the project, just warn...
warn_user_once!("The pinned Python version ({version}) in `{}` does not meet the project's Python requirement of `{requires_python}`.", path.user_display().cyan());
} 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());
should_use = false;
}
}
}
}
}

if should_use {
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())));
}

Ok(request)
}
45 changes: 4 additions & 41 deletions crates/uv/src/commands/python/find.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,10 @@ use anstream::println;
use anyhow::Result;

use uv_cache::Cache;
use uv_fs::{Simplified, CWD};
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_fs::Simplified;
use uv_python::{EnvironmentPreference, PythonInstallation, PythonPreference};

use crate::commands::{project::find_requires_python, ExitStatus};
use crate::commands::{project::find_python_request, ExitStatus};

/// Find a Python interpreter.
pub(crate) async fn find(
Expand All @@ -21,38 +15,7 @@ pub(crate) async fn find(
python_preference: PythonPreference,
cache: &Cache,
) -> Result<ExitStatus> {
// (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(&*CWD, no_config)
.await?
.and_then(PythonVersionFile::into_version);
}

// (3) `Requires-Python` in `pyproject.toml`
if request.is_none() && !no_project {
let project = match VirtualProject::discover(&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
}
};

if let Some(project) = project {
request = find_requires_python(project.workspace())?
.as_ref()
.map(RequiresPython::specifiers)
.map(|specifiers| {
PythonRequest::Version(VersionRequest::Range(specifiers.clone()))
});
}
}
let request = find_python_request(request, no_project, no_config).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 @@ -153,7 +153,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
72 changes: 66 additions & 6 deletions crates/uv/tests/python_find.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,15 +232,15 @@ 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
.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
requires-python = ">=3.11"
dependencies = ["anyio==3.7.0"]
"#})
.unwrap();
Expand All @@ -250,17 +250,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 -----
"###);
Expand All @@ -270,7 +270,67 @@ fn python_find_project() {
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.11]
[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 -----
"###);

// And we should warn if it's not compatible with the project
uv_snapshot!(context.filters(), context.python_pin().arg("3.10"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: The requested Python version `3.10` is incompatible with the project `requires-python` value of `>=3.11`.
"###);
uv_snapshot!(context.filters(), context.python_find(), @r###"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.12]
----- stderr -----
"###);

// Unless the pin file is outside the project, in which case we can 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.12]
----- stderr -----
"###);
Expand Down

0 comments on commit 5b48cf1

Please sign in to comment.