@@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
4
4
use fs_err as fs;
5
5
use itertools:: Itertools ;
6
6
use tracing:: debug;
7
+ use uv_fs:: Simplified ;
7
8
8
9
use crate :: PythonRequest ;
9
10
@@ -22,38 +23,91 @@ pub struct PythonVersionFile {
22
23
versions : Vec < PythonRequest > ,
23
24
}
24
25
26
+ /// Whether to prefer the `.python-version` or `.python-versions` file.
27
+ #[ derive( Debug , Clone , Copy , Default ) ]
28
+ pub enum FilePreference {
29
+ #[ default]
30
+ Version ,
31
+ Versions ,
32
+ }
33
+
34
+ #[ derive( Debug , Default , Clone ) ]
35
+ pub struct DiscoveryOptions < ' a > {
36
+ /// The path to stop discovery at.
37
+ stop_discovery_at : Option < & ' a Path > ,
38
+ /// When `no_config` is set, Python version files will be ignored.
39
+ ///
40
+ /// Discovery will still run in order to display a log about the ignored file.
41
+ no_config : bool ,
42
+ preference : FilePreference ,
43
+ }
44
+
45
+ impl < ' a > DiscoveryOptions < ' a > {
46
+ #[ must_use]
47
+ pub fn with_no_config ( self , no_config : bool ) -> Self {
48
+ Self { no_config, ..self }
49
+ }
50
+
51
+ #[ must_use]
52
+ pub fn with_preference ( self , preference : FilePreference ) -> Self {
53
+ Self { preference, ..self }
54
+ }
55
+
56
+ #[ must_use]
57
+ pub fn with_stop_discovery_at ( self , stop_discovery_at : Option < & ' a Path > ) -> Self {
58
+ Self {
59
+ stop_discovery_at,
60
+ ..self
61
+ }
62
+ }
63
+ }
64
+
25
65
impl PythonVersionFile {
26
- /// Find a Python version file in the given directory.
66
+ /// Find a Python version file in the given directory or any of its parents .
27
67
pub async fn discover (
28
68
working_directory : impl AsRef < Path > ,
29
- // TODO(zanieb): Create a `DiscoverySettings` struct for these options
30
- no_config : bool ,
31
- prefer_versions : bool ,
69
+ options : & DiscoveryOptions < ' _ > ,
32
70
) -> 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 ) ;
35
-
36
- 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
- } ;
71
+ let Some ( path ) = Self :: find_nearest ( working_directory , options ) else {
72
+ return Ok ( None ) ;
73
+ } ;
74
+
75
+ if options . no_config {
76
+ debug ! (
77
+ "Ignoring Python version file at `{}` due to `--no-config`" ,
78
+ path . user_display ( )
79
+ ) ;
42
80
return Ok ( None ) ;
43
81
}
44
82
45
- let paths = if prefer_versions {
46
- [ versions_path, version_path]
47
- } else {
48
- [ version_path, versions_path]
83
+ // Uses `try_from_path` instead of `from_path` to avoid TOCTOU failures.
84
+ Self :: try_from_path ( path) . await
85
+ }
86
+
87
+ fn find_nearest ( path : impl AsRef < Path > , options : & DiscoveryOptions < ' _ > ) -> Option < PathBuf > {
88
+ path. as_ref ( )
89
+ . ancestors ( )
90
+ . take_while ( |path| {
91
+ // Only walk up the given directory, if any.
92
+ options
93
+ . stop_discovery_at
94
+ . and_then ( Path :: parent)
95
+ . map ( |stop_discovery_at| stop_discovery_at != * path)
96
+ . unwrap_or ( true )
97
+ } )
98
+ . find_map ( |path| Self :: find_in_directory ( path, options) )
99
+ }
100
+
101
+ fn find_in_directory ( path : & Path , options : & DiscoveryOptions < ' _ > ) -> Option < PathBuf > {
102
+ let version_path = path. join ( PYTHON_VERSION_FILENAME ) ;
103
+ let versions_path = path. join ( PYTHON_VERSIONS_FILENAME ) ;
104
+
105
+ let paths = match options. preference {
106
+ FilePreference :: Versions => [ versions_path, version_path] ,
107
+ FilePreference :: Version => [ version_path, versions_path] ,
49
108
} ;
50
- for path in paths {
51
- if let Some ( result) = Self :: try_from_path ( path) . await ? {
52
- return Ok ( Some ( result) ) ;
53
- } ;
54
- }
55
109
56
- Ok ( None )
110
+ paths . into_iter ( ) . find ( |path| path . is_file ( ) )
57
111
}
58
112
59
113
/// Try to read a Python version file at the given path.
@@ -62,7 +116,10 @@ impl PythonVersionFile {
62
116
pub async fn try_from_path ( path : PathBuf ) -> Result < Option < Self > , std:: io:: Error > {
63
117
match fs:: tokio:: read_to_string ( & path) . await {
64
118
Ok ( content) => {
65
- debug ! ( "Reading requests from `{}`" , path. display( ) ) ;
119
+ debug ! (
120
+ "Reading Python requests from version file at `{}`" ,
121
+ path. display( )
122
+ ) ;
66
123
let versions = content
67
124
. lines ( )
68
125
. filter ( |line| {
@@ -104,7 +161,7 @@ impl PythonVersionFile {
104
161
}
105
162
}
106
163
107
- /// Return the first version declared in the file, if any.
164
+ /// Return the first request declared in the file, if any.
108
165
pub fn version ( & self ) -> Option < & PythonRequest > {
109
166
self . versions . first ( )
110
167
}
0 commit comments