Skip to content

Commit ff3d300

Browse files
committed
Improve interactions between .python-version files and project requires-python
1 parent 2ca5bb4 commit ff3d300

File tree

4 files changed

+188
-49
lines changed

4 files changed

+188
-49
lines changed

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

Lines changed: 115 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use uv_client::{BaseClientBuilder, Connectivity, FlatIndexClient, RegistryClient
1414
use uv_configuration::{Concurrency, ExtrasSpecification, Reinstall, Upgrade};
1515
use uv_dispatch::BuildDispatch;
1616
use uv_distribution::DistributionDatabase;
17-
use uv_fs::Simplified;
17+
use uv_fs::{Simplified, CWD};
1818
use uv_installer::{SatisfiesResult, SitePackages};
1919
use uv_normalize::PackageName;
2020
use uv_python::{
@@ -27,7 +27,7 @@ use uv_resolver::{
2727
};
2828
use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy};
2929
use uv_warnings::{warn_user, warn_user_once};
30-
use uv_workspace::Workspace;
30+
use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceError};
3131

3232
use crate::commands::pip::loggers::{InstallLogger, ResolveLogger};
3333
use crate::commands::pip::operations::{Changelog, Modifications};
@@ -36,6 +36,8 @@ use crate::commands::{pip, SharedState};
3636
use crate::printer::Printer;
3737
use crate::settings::{InstallerSettingsRef, ResolverInstallerSettings, ResolverSettingsRef};
3838

39+
use super::python::pin::pep440_version_from_request;
40+
3941
pub(crate) mod add;
4042
pub(crate) mod environment;
4143
pub(crate) mod init;
@@ -1048,3 +1050,114 @@ fn warn_on_requirements_txt_setting(
10481050
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.");
10491051
}
10501052
}
1053+
1054+
/// Determine the [`PythonRequest`] to use in a command, if any.
1055+
pub(crate) async fn find_python_request(
1056+
user_request: Option<String>,
1057+
no_project: bool,
1058+
no_config: bool,
1059+
) -> Result<Option<PythonRequest>, ProjectError> {
1060+
// (1) Explicit request from user
1061+
let mut request = user_request.map(|request| PythonRequest::parse(&request));
1062+
1063+
let (project, requires_python) = if no_project {
1064+
(None, None)
1065+
} else {
1066+
let project = match VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await {
1067+
Ok(project) => Some(project),
1068+
Err(WorkspaceError::MissingProject(_)) => None,
1069+
Err(WorkspaceError::MissingPyprojectToml) => None,
1070+
Err(WorkspaceError::NonWorkspace(_)) => None,
1071+
Err(err) => {
1072+
warn_user_once!("{err}");
1073+
None
1074+
}
1075+
};
1076+
1077+
let requires_python = if let Some(project) = project.as_ref() {
1078+
find_requires_python(project.workspace())?
1079+
} else {
1080+
None
1081+
};
1082+
1083+
(project, requires_python)
1084+
};
1085+
1086+
// (2) Request from a `.python-version` file
1087+
if request.is_none() {
1088+
let version_file = PythonVersionFile::discover(&*CWD, no_config, false).await?;
1089+
1090+
if should_use_version_file(
1091+
version_file.as_ref(),
1092+
requires_python.as_ref(),
1093+
project.as_ref(),
1094+
) {
1095+
request = version_file.and_then(PythonVersionFile::into_version);
1096+
}
1097+
}
1098+
1099+
// (3) The `requires-python` defined in `pyproject.toml`
1100+
if request.is_none() && !no_project {
1101+
request = requires_python
1102+
.as_ref()
1103+
.map(RequiresPython::specifiers)
1104+
.map(|specifiers| PythonRequest::Version(VersionRequest::Range(specifiers.clone())));
1105+
}
1106+
1107+
Ok(request)
1108+
}
1109+
1110+
/// Determine if a version file should be used, w.r.t, a Python requirement.
1111+
///
1112+
/// If the version file is incompatible,
1113+
fn should_use_version_file(
1114+
version_file: Option<&PythonVersionFile>,
1115+
requires_python: Option<&RequiresPython>,
1116+
project: Option<&VirtualProject>,
1117+
) -> bool {
1118+
// If there's no file, it's moot
1119+
let Some(version_file) = version_file else {
1120+
return false;
1121+
};
1122+
1123+
// If there's no request in the file, there's nothing to do
1124+
let Some(request) = version_file.version() else {
1125+
return false;
1126+
};
1127+
1128+
// If there's no project Python requirement, it's compatible
1129+
let Some(requires_python) = &requires_python else {
1130+
return true;
1131+
};
1132+
1133+
// If the request can't be parsed as a version, we can't check compatibility
1134+
let Some(version) = pep440_version_from_request(request) else {
1135+
return true;
1136+
};
1137+
1138+
// If it's compatible with the requirement, we can definitely use it
1139+
if requires_python.specifiers().contains(&version) {
1140+
return true;
1141+
};
1142+
1143+
let path = version_file.path();
1144+
1145+
// If there's no known project, we're not sure where the Python requirement came from and it's
1146+
// not safe to use the pin
1147+
let Some(project) = project else {
1148+
debug!("Ignoring pinned Python version ({version}) at `{}`, it does not meet the Python requirement of `{requires_python}`.", path.user_display().cyan());
1149+
return false;
1150+
};
1151+
1152+
// Otherwise, whether or not we should use it depends if it's declared inside or outside of the
1153+
// project.
1154+
if path.starts_with(project.root()) {
1155+
// It's the pin is declared _inside_ the project, just warn... but use the version
1156+
warn_user_once!("The pinned Python version ({version}) in `{}` does not meet the project's Python requirement of `{requires_python}`.", path.user_display().cyan());
1157+
true
1158+
} else {
1159+
// Otherwise, we can just ignore the pin — it's outside the project
1160+
debug!("Ignoring pinned Python version ({version}) at `{}`, it does not meet the project's Python requirement of `{requires_python}`.", path.user_display().cyan());
1161+
false
1162+
}
1163+
}

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

Lines changed: 4 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,10 @@ use anstream::println;
22
use anyhow::Result;
33

44
use uv_cache::Cache;
5-
use uv_fs::{Simplified, CWD};
6-
use uv_python::{
7-
EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile,
8-
VersionRequest,
9-
};
10-
use uv_resolver::RequiresPython;
11-
use uv_warnings::warn_user_once;
12-
use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError};
5+
use uv_fs::Simplified;
6+
use uv_python::{EnvironmentPreference, PythonInstallation, PythonPreference};
137

14-
use crate::commands::{project::find_requires_python, ExitStatus};
8+
use crate::commands::{project::find_python_request, ExitStatus};
159

1610
/// Find a Python interpreter.
1711
pub(crate) async fn find(
@@ -21,38 +15,7 @@ pub(crate) async fn find(
2115
python_preference: PythonPreference,
2216
cache: &Cache,
2317
) -> Result<ExitStatus> {
24-
// (1) Explicit request from user
25-
let mut request = request.map(|request| PythonRequest::parse(&request));
26-
27-
// (2) Request from `.python-version`
28-
if request.is_none() {
29-
request = PythonVersionFile::discover(&*CWD, no_config, false)
30-
.await?
31-
.and_then(PythonVersionFile::into_version);
32-
}
33-
34-
// (3) `Requires-Python` in `pyproject.toml`
35-
if request.is_none() && !no_project {
36-
let project = match VirtualProject::discover(&CWD, &DiscoveryOptions::default()).await {
37-
Ok(project) => Some(project),
38-
Err(WorkspaceError::MissingProject(_)) => None,
39-
Err(WorkspaceError::MissingPyprojectToml) => None,
40-
Err(WorkspaceError::NonWorkspace(_)) => None,
41-
Err(err) => {
42-
warn_user_once!("{err}");
43-
None
44-
}
45-
};
46-
47-
if let Some(project) = project {
48-
request = find_requires_python(project.workspace())?
49-
.as_ref()
50-
.map(RequiresPython::specifiers)
51-
.map(|specifiers| {
52-
PythonRequest::Version(VersionRequest::Range(specifiers.clone()))
53-
});
54-
}
55-
}
18+
let request = find_python_request(request, no_project, no_config).await?;
5619

5720
let python = PythonInstallation::find(
5821
&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
@@ -153,7 +153,7 @@ pub(crate) async fn pin(
153153
Ok(ExitStatus::Success)
154154
}
155155

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

crates/uv/tests/python_find.rs

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,15 +232,15 @@ fn python_find_pin() {
232232

233233
#[test]
234234
fn python_find_project() {
235-
let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]);
235+
let context: TestContext = TestContext::new_with_versions(&["3.10", "3.11", "3.12"]);
236236

237237
let pyproject_toml = context.temp_dir.child("pyproject.toml");
238238
pyproject_toml
239239
.write_str(indoc! {r#"
240240
[project]
241241
name = "project"
242242
version = "0.1.0"
243-
requires-python = ">=3.12"
243+
requires-python = ">=3.11"
244244
dependencies = ["anyio==3.7.0"]
245245
"#})
246246
.unwrap();
@@ -250,17 +250,17 @@ fn python_find_project() {
250250
success: true
251251
exit_code: 0
252252
----- stdout -----
253-
[PYTHON-3.12]
253+
[PYTHON-3.11]
254254
255255
----- stderr -----
256256
"###);
257257

258258
// Unless explicitly requested
259-
uv_snapshot!(context.filters(), context.python_find().arg("3.11"), @r###"
259+
uv_snapshot!(context.filters(), context.python_find().arg("3.10"), @r###"
260260
success: true
261261
exit_code: 0
262262
----- stdout -----
263-
[PYTHON-3.11]
263+
[PYTHON-3.10]
264264
265265
----- stderr -----
266266
"###);
@@ -270,6 +270,69 @@ fn python_find_project() {
270270
success: true
271271
exit_code: 0
272272
----- stdout -----
273+
[PYTHON-3.10]
274+
275+
----- stderr -----
276+
"###);
277+
278+
// But a pin should take precedence
279+
uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###"
280+
success: true
281+
exit_code: 0
282+
----- stdout -----
283+
Pinned `.python-version` to `3.12`
284+
285+
----- stderr -----
286+
"###);
287+
uv_snapshot!(context.filters(), context.python_find(), @r###"
288+
success: true
289+
exit_code: 0
290+
----- stdout -----
291+
[PYTHON-3.12]
292+
293+
----- stderr -----
294+
"###);
295+
296+
// Create a pin that's incompatible with the project
297+
uv_snapshot!(context.filters(), context.python_pin().arg("3.10").arg("--no-workspace"), @r###"
298+
success: true
299+
exit_code: 0
300+
----- stdout -----
301+
Updated `.python-version` from `3.12` -> `3.10`
302+
303+
----- stderr -----
304+
"###);
305+
306+
// We should warn on subsequent uses, but respect the version?
307+
uv_snapshot!(context.filters(), context.python_find(), @r###"
308+
success: true
309+
exit_code: 0
310+
----- stdout -----
311+
[PYTHON-3.10]
312+
313+
----- stderr -----
314+
warning: The pinned Python version (3.10) in `.python-version` does not meet the project's Python requirement of `>=3.11`.
315+
"###);
316+
317+
// Unless the pin file is outside the project, in which case we just ignore it
318+
let child_dir = context.temp_dir.child("child");
319+
child_dir.create_dir_all().unwrap();
320+
321+
let pyproject_toml = child_dir.child("pyproject.toml");
322+
pyproject_toml
323+
.write_str(indoc! {r#"
324+
[project]
325+
name = "project"
326+
version = "0.1.0"
327+
requires-python = ">=3.11"
328+
dependencies = ["anyio==3.7.0"]
329+
"#})
330+
.unwrap();
331+
332+
uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir), @r###"
333+
success: true
334+
exit_code: 0
335+
----- stdout -----
273336
[PYTHON-3.11]
274337
275338
----- stderr -----

0 commit comments

Comments
 (0)