Skip to content

Commit 3564e75

Browse files
committed
Discover and respect .python-version files in parent directories
1 parent 0e8bf62 commit 3564e75

File tree

2 files changed

+67
-17
lines changed

2 files changed

+67
-17
lines changed

crates/uv-python/src/version_files.rs

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
44
use fs_err as fs;
55
use itertools::Itertools;
66
use tracing::debug;
7+
use uv_fs::Simplified;
78

89
use crate::PythonRequest;
910

@@ -23,37 +24,54 @@ pub struct PythonVersionFile {
2324
}
2425

2526
impl PythonVersionFile {
26-
/// Find a Python version file in the given directory.
27+
/// Find a Python version file in the given directory or any of its parents.
2728
pub async fn discover(
2829
working_directory: impl AsRef<Path>,
2930
// TODO(zanieb): Create a `DiscoverySettings` struct for these options
3031
no_config: bool,
3132
prefer_versions: bool,
3233
) -> Result<Option<Self>, std::io::Error> {
33-
let versions_path = working_directory.as_ref().join(PYTHON_VERSIONS_FILENAME);
34-
let version_path = working_directory.as_ref().join(PYTHON_VERSION_FILENAME);
34+
let Some(path) = Self::find_nearest(working_directory, prefer_versions) else {
35+
return Ok(None);
36+
};
3537

3638
if no_config {
37-
if version_path.exists() {
38-
debug!("Ignoring `.python-version` file due to `--no-config`");
39-
} else if versions_path.exists() {
40-
debug!("Ignoring `.python-versions` file due to `--no-config`");
41-
};
39+
debug!(
40+
"Ignoring Python version file at `{}` due to `--no-config`",
41+
path.user_display()
42+
);
4243
return Ok(None);
4344
}
4445

45-
let paths = if prefer_versions {
46-
[versions_path, version_path]
47-
} else {
48-
[version_path, versions_path]
49-
};
50-
for path in paths {
51-
if let Some(result) = Self::try_from_path(path).await? {
52-
return Ok(Some(result));
46+
// Uses `try_from_path` instead of `from_path` to avoid TOCTOU failures.
47+
Self::try_from_path(path).await
48+
}
49+
50+
fn find_nearest(working_directory: impl AsRef<Path>, prefer_versions: bool) -> Option<PathBuf> {
51+
let mut current = working_directory.as_ref();
52+
loop {
53+
let version_path = current.join(PYTHON_VERSION_FILENAME);
54+
let versions_path = current.join(PYTHON_VERSIONS_FILENAME);
55+
56+
let paths = if prefer_versions {
57+
[versions_path, version_path]
58+
} else {
59+
[version_path, versions_path]
5360
};
61+
for path in paths {
62+
if path.exists() {
63+
return Some(path);
64+
}
65+
}
66+
67+
if let Some(parent) = current.parent() {
68+
current = parent;
69+
} else {
70+
break;
71+
}
5472
}
5573

56-
Ok(None)
74+
None
5775
}
5876

5977
/// Try to read a Python version file at the given path.

crates/uv/tests/python_find.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,38 @@ fn python_find_pin() {
197197
198198
----- stderr -----
199199
"###);
200+
201+
let child_dir = context.temp_dir.child("child");
202+
child_dir.create_dir_all().unwrap();
203+
204+
// We should also find pinned versions in the parent directory
205+
uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir), @r###"
206+
success: true
207+
exit_code: 0
208+
----- stdout -----
209+
[PYTHON-3.12]
210+
211+
----- stderr -----
212+
"###);
213+
214+
uv_snapshot!(context.filters(), context.python_pin().arg("3.11").current_dir(&child_dir), @r###"
215+
success: true
216+
exit_code: 0
217+
----- stdout -----
218+
Updated `.python-version` from `3.12` -> `3.11`
219+
220+
----- stderr -----
221+
"###);
222+
223+
// Unless the child directory also has a pin
224+
uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir), @r###"
225+
success: true
226+
exit_code: 0
227+
----- stdout -----
228+
[PYTHON-3.11]
229+
230+
----- stderr -----
231+
"###);
200232
}
201233

202234
#[test]

0 commit comments

Comments
 (0)