@@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
44use fs_err as fs;
55use itertools:: Itertools ;
66use tracing:: debug;
7+ use uv_fs:: Simplified ;
78
89use crate :: PythonRequest ;
910
@@ -22,38 +23,91 @@ pub struct PythonVersionFile {
2223 versions : Vec < PythonRequest > ,
2324}
2425
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+
2565impl 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 .
2767 pub async fn discover (
2868 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 < ' _ > ,
3270 ) -> 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+ ) ;
4280 return Ok ( None ) ;
4381 }
4482
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] ,
49108 } ;
50- for path in paths {
51- if let Some ( result) = Self :: try_from_path ( path) . await ? {
52- return Ok ( Some ( result) ) ;
53- } ;
54- }
55109
56- Ok ( None )
110+ paths . into_iter ( ) . find ( |path| path . is_file ( ) )
57111 }
58112
59113 /// Try to read a Python version file at the given path.
@@ -62,7 +116,10 @@ impl PythonVersionFile {
62116 pub async fn try_from_path ( path : PathBuf ) -> Result < Option < Self > , std:: io:: Error > {
63117 match fs:: tokio:: read_to_string ( & path) . await {
64118 Ok ( content) => {
65- debug ! ( "Reading requests from `{}`" , path. display( ) ) ;
119+ debug ! (
120+ "Reading Python requests from version file at `{}`" ,
121+ path. display( )
122+ ) ;
66123 let versions = content
67124 . lines ( )
68125 . filter ( |line| {
@@ -104,7 +161,7 @@ impl PythonVersionFile {
104161 }
105162 }
106163
107- /// Return the first version declared in the file, if any.
164+ /// Return the first request declared in the file, if any.
108165 pub fn version ( & self ) -> Option < & PythonRequest > {
109166 self . versions . first ( )
110167 }
0 commit comments