Skip to content

Commit fdde4bf

Browse files
committed
Improve interactions between .python-version files and project requires-python
1 parent 3564e75 commit fdde4bf

File tree

6 files changed

+276
-96
lines changed

6 files changed

+276
-96
lines changed

crates/uv/src/commands/project/mod.rs

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClient
1515
use uv_configuration::{Concurrency, Constraints, ExtrasSpecification, Reinstall, Upgrade};
1616
use uv_dispatch::BuildDispatch;
1717
use uv_distribution::DistributionDatabase;
18-
use uv_fs::Simplified;
18+
use uv_fs::{Simplified, CWD};
1919
use uv_git::ResolvedRepositoryReference;
2020
use uv_installer::{SatisfiesResult, SitePackages};
2121
use uv_normalize::PackageName;
@@ -33,7 +33,7 @@ use uv_resolver::{
3333
};
3434
use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy};
3535
use uv_warnings::{warn_user, warn_user_once};
36-
use uv_workspace::Workspace;
36+
use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceError};
3737

3838
use crate::commands::pip::loggers::{InstallLogger, ResolveLogger};
3939
use crate::commands::pip::operations::{Changelog, Modifications};
@@ -42,6 +42,8 @@ use crate::commands::{pip, SharedState};
4242
use crate::printer::Printer;
4343
use crate::settings::{InstallerSettingsRef, ResolverInstallerSettings, ResolverSettingsRef};
4444

45+
use super::python::pin::pep440_version_from_request;
46+
4547
pub(crate) mod add;
4648
pub(crate) mod environment;
4749
pub(crate) mod export;
@@ -1362,3 +1364,134 @@ fn warn_on_requirements_txt_setting(
13621364
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.");
13631365
}
13641366
}
1367+
1368+
/// Determine the [`PythonRequest`] to use in a command, if any.
1369+
pub(crate) async fn python_request_from_args(
1370+
user_request: Option<&str>,
1371+
no_project: bool,
1372+
no_config: bool,
1373+
project_dir: Option<&Path>,
1374+
project: Option<VirtualProject>,
1375+
) -> Result<Option<PythonRequest>, ProjectError> {
1376+
// (1) Explicit request from user
1377+
let mut request = user_request.map(PythonRequest::parse);
1378+
1379+
let project = if no_project {
1380+
None
1381+
} else {
1382+
// If given a project, use it. Otherwise, discover the project.
1383+
if let Some(project) = project {
1384+
Some(project)
1385+
} else {
1386+
match VirtualProject::discover(
1387+
project_dir.unwrap_or(&*CWD),
1388+
&DiscoveryOptions::default(),
1389+
)
1390+
.await
1391+
{
1392+
Ok(project) => Some(project),
1393+
Err(WorkspaceError::MissingProject(_)) => None,
1394+
Err(WorkspaceError::MissingPyprojectToml) => None,
1395+
Err(WorkspaceError::NonWorkspace(_)) => None,
1396+
Err(err) => {
1397+
warn_user_once!("{err}");
1398+
None
1399+
}
1400+
}
1401+
}
1402+
};
1403+
1404+
let requires_python = if let Some(project) = project.as_ref() {
1405+
find_requires_python(project.workspace())?
1406+
} else {
1407+
None
1408+
};
1409+
1410+
// (2) Request from a `.python-version` file
1411+
if request.is_none() {
1412+
let version_file = PythonVersionFile::discover(
1413+
project
1414+
.as_ref()
1415+
.map(|project| project.workspace().install_path())
1416+
.unwrap_or(&*CWD),
1417+
no_config,
1418+
false,
1419+
)
1420+
.await?;
1421+
1422+
if should_use_version_file(
1423+
version_file.as_ref(),
1424+
requires_python.as_ref(),
1425+
project.as_ref(),
1426+
) {
1427+
request = version_file.and_then(PythonVersionFile::into_version);
1428+
}
1429+
}
1430+
1431+
// (3) The `requires-python` defined in `pyproject.toml`
1432+
if request.is_none() && !no_project {
1433+
request = requires_python
1434+
.as_ref()
1435+
.map(RequiresPython::specifiers)
1436+
.map(|specifiers| {
1437+
PythonRequest::Version(VersionRequest::Range(specifiers.clone(), false))
1438+
});
1439+
}
1440+
1441+
Ok(request)
1442+
}
1443+
1444+
/// Determine if a version file should be used, w.r.t, a Python requirement.
1445+
///
1446+
/// If the version file is incompatible,
1447+
fn should_use_version_file(
1448+
version_file: Option<&PythonVersionFile>,
1449+
requires_python: Option<&RequiresPython>,
1450+
project: Option<&VirtualProject>,
1451+
) -> bool {
1452+
// If there's no file, it's moot
1453+
let Some(version_file) = version_file else {
1454+
return false;
1455+
};
1456+
1457+
// If there's no request in the file, there's nothing to do
1458+
let Some(request) = version_file.version() else {
1459+
return false;
1460+
};
1461+
1462+
// If there's no project Python requirement, it's compatible
1463+
let Some(requires_python) = &requires_python else {
1464+
return true;
1465+
};
1466+
1467+
// If the request can't be parsed as a version, we can't check compatibility
1468+
let Some(version) = pep440_version_from_request(request) else {
1469+
return true;
1470+
};
1471+
1472+
// If it's compatible with the requirement, we can definitely use it
1473+
if requires_python.specifiers().contains(&version) {
1474+
return true;
1475+
};
1476+
1477+
let path = version_file.path();
1478+
1479+
// If there's no known project, we're not sure where the Python requirement came from and it's
1480+
// not safe to use the pin
1481+
let Some(project) = project else {
1482+
debug!("Ignoring pinned Python version ({version}) at `{}`, it does not meet the Python requirement of `{requires_python}`.", path.user_display().cyan());
1483+
return false;
1484+
};
1485+
1486+
// Otherwise, whether or not we should use it depends if it's declared inside or outside of the
1487+
// project.
1488+
if path.starts_with(project.root()) {
1489+
// It's the pin is declared _inside_ the project, just warn... but use the version
1490+
warn_user_once!("The pinned Python version ({version}) in `{}` does not meet the project's Python requirement of `{requires_python}`.", path.user_display().cyan());
1491+
true
1492+
} else {
1493+
// Otherwise, we can just ignore the pin — it's outside the project
1494+
debug!("Ignoring pinned Python version ({version}) at `{}`, it does not meet the project's Python requirement of `{requires_python}`.", path.user_display().cyan());
1495+
false
1496+
}
1497+
}

crates/uv/src/commands/python/find.rs

Lines changed: 10 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,9 @@ use std::path::Path;
44

55
use uv_cache::Cache;
66
use uv_fs::Simplified;
7-
use uv_python::{
8-
EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile,
9-
VersionRequest,
10-
};
11-
use uv_resolver::RequiresPython;
12-
use uv_warnings::warn_user_once;
13-
use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError};
7+
use uv_python::{EnvironmentPreference, PythonInstallation, PythonPreference};
148

15-
use crate::commands::{project::find_requires_python, ExitStatus};
9+
use crate::commands::{project::python_request_from_args, ExitStatus};
1610

1711
/// Find a Python interpreter.
1812
pub(crate) async fn find(
@@ -30,39 +24,14 @@ pub(crate) async fn find(
3024
EnvironmentPreference::Any
3125
};
3226

33-
// (1) Explicit request from user
34-
let mut request = request.map(|request| PythonRequest::parse(&request));
35-
36-
// (2) Request from `.python-version`
37-
if request.is_none() {
38-
request = PythonVersionFile::discover(project_dir, no_config, false)
39-
.await?
40-
.and_then(PythonVersionFile::into_version);
41-
}
42-
43-
// (3) `Requires-Python` in `pyproject.toml`
44-
if request.is_none() && !no_project {
45-
let project =
46-
match VirtualProject::discover(project_dir, &DiscoveryOptions::default()).await {
47-
Ok(project) => Some(project),
48-
Err(WorkspaceError::MissingProject(_)) => None,
49-
Err(WorkspaceError::MissingPyprojectToml) => None,
50-
Err(WorkspaceError::NonWorkspace(_)) => None,
51-
Err(err) => {
52-
warn_user_once!("{err}");
53-
None
54-
}
55-
};
56-
57-
if let Some(project) = project {
58-
request = find_requires_python(project.workspace())?
59-
.as_ref()
60-
.map(RequiresPython::specifiers)
61-
.map(|specifiers| {
62-
PythonRequest::Version(VersionRequest::Range(specifiers.clone(), false))
63-
});
64-
}
65-
}
27+
let request = python_request_from_args(
28+
request.as_deref(),
29+
no_project,
30+
no_config,
31+
Some(project_dir),
32+
None,
33+
)
34+
.await?;
6635

6736
let python = PythonInstallation::find(
6837
&request.unwrap_or_default(),

crates/uv/src/commands/python/pin.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ pub(crate) async fn pin(
155155
Ok(ExitStatus::Success)
156156
}
157157

158-
fn pep440_version_from_request(request: &PythonRequest) -> Option<pep440_rs::Version> {
158+
pub(crate) fn pep440_version_from_request(request: &PythonRequest) -> Option<pep440_rs::Version> {
159159
let version_request = match request {
160160
PythonRequest::Version(ref version)
161161
| PythonRequest::ImplementationVersion(_, ref version) => version,

crates/uv/src/commands/venv.rs

Lines changed: 14 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,23 +21,21 @@ use uv_configuration::{
2121
};
2222
use uv_dispatch::BuildDispatch;
2323
use uv_fs::Simplified;
24-
use uv_python::{
25-
EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest,
26-
PythonVersionFile, VersionRequest,
27-
};
28-
use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython};
24+
use uv_python::{EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference};
25+
use uv_resolver::{ExcludeNewer, FlatIndex};
2926
use uv_shell::Shell;
3027
use uv_types::{BuildContext, BuildIsolation, HashStrategy};
3128
use uv_warnings::warn_user_once;
3229
use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError};
3330

3431
use crate::commands::pip::loggers::{DefaultInstallLogger, InstallLogger};
3532
use crate::commands::pip::operations::Changelog;
36-
use crate::commands::project::find_requires_python;
3733
use crate::commands::reporters::PythonDownloadReporter;
3834
use crate::commands::{ExitStatus, SharedState};
3935
use crate::printer::Printer;
4036

37+
use super::project::python_request_from_args;
38+
4139
/// Create a virtual environment.
4240
#[allow(clippy::unnecessary_wraps, clippy::fn_params_excessive_bools)]
4341
pub(crate) async fn venv(
@@ -184,33 +182,19 @@ async fn venv_impl(
184182

185183
let reporter = PythonDownloadReporter::single(printer);
186184

187-
// (1) Explicit request from user
188-
let mut interpreter_request = python_request.map(PythonRequest::parse);
189-
190-
// (2) Request from `.python-version`
191-
if interpreter_request.is_none() {
192-
interpreter_request = PythonVersionFile::discover(project_dir, no_config, false)
193-
.await
194-
.into_diagnostic()?
195-
.and_then(PythonVersionFile::into_version);
196-
}
197-
198-
// (3) `Requires-Python` in `pyproject.toml`
199-
if interpreter_request.is_none() {
200-
if let Some(project) = project {
201-
interpreter_request = find_requires_python(project.workspace())
202-
.into_diagnostic()?
203-
.as_ref()
204-
.map(RequiresPython::specifiers)
205-
.map(|specifiers| {
206-
PythonRequest::Version(VersionRequest::Range(specifiers.clone(), false))
207-
});
208-
}
209-
}
185+
let python_request = python_request_from_args(
186+
python_request,
187+
no_project,
188+
no_config,
189+
Some(project_dir),
190+
project,
191+
)
192+
.await
193+
.into_diagnostic()?;
210194

211195
// Locate the Python interpreter to use in the environment
212196
let python = PythonInstallation::find_or_download(
213-
interpreter_request.as_ref(),
197+
python_request.as_ref(),
214198
EnvironmentPreference::OnlySystem,
215199
python_preference,
216200
python_downloads,

0 commit comments

Comments
 (0)