@@ -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,88 @@ 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+ no_config : bool ,
39+ preference : FilePreference ,
40+ }
41+
42+ impl < ' a > DiscoveryOptions < ' a > {
43+ #[ must_use]
44+ pub fn with_no_config ( self , no_config : bool ) -> Self {
45+ Self { no_config, ..self }
46+ }
47+
48+ #[ must_use]
49+ pub fn with_preference ( self , preference : FilePreference ) -> Self {
50+ Self { preference, ..self }
51+ }
52+
53+ #[ must_use]
54+ pub fn with_stop_discovery_at ( self , stop_discovery_at : Option < & ' a Path > ) -> Self {
55+ Self {
56+ stop_discovery_at,
57+ ..self
58+ }
59+ }
60+ }
61+
2562impl PythonVersionFile {
26- /// Find a Python version file in the given directory.
63+ /// Find a Python version file in the given directory or any of its parents .
2764 pub async fn discover (
2865 working_directory : impl AsRef < Path > ,
29- // TODO(zanieb): Create a `DiscoverySettings` struct for these options
30- no_config : bool ,
31- prefer_versions : bool ,
66+ options : & DiscoveryOptions < ' _ > ,
3267 ) -> 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- } ;
68+ let Some ( path ) = Self :: find_nearest ( working_directory , options ) else {
69+ return Ok ( None ) ;
70+ } ;
71+
72+ if options . no_config {
73+ debug ! (
74+ "Ignoring Python version file at `{}` due to `--no-config`" ,
75+ path . user_display ( )
76+ ) ;
4277 return Ok ( None ) ;
4378 }
4479
45- let paths = if prefer_versions {
46- [ versions_path, version_path]
47- } else {
48- [ version_path, versions_path]
80+ // Uses `try_from_path` instead of `from_path` to avoid TOCTOU failures.
81+ Self :: try_from_path ( path) . await
82+ }
83+
84+ fn find_nearest ( path : impl AsRef < Path > , options : & DiscoveryOptions < ' _ > ) -> Option < PathBuf > {
85+ path. as_ref ( )
86+ . ancestors ( )
87+ . take_while ( |path| {
88+ // Only walk up the given directory, if any.
89+ options
90+ . stop_discovery_at
91+ . and_then ( Path :: parent)
92+ . map ( |stop_discovery_at| stop_discovery_at != * path)
93+ . unwrap_or ( true )
94+ } )
95+ . find_map ( |path| Self :: find_in_directory ( path, options) )
96+ }
97+
98+ fn find_in_directory ( path : & Path , options : & DiscoveryOptions < ' _ > ) -> Option < PathBuf > {
99+ let version_path = path. join ( PYTHON_VERSION_FILENAME ) ;
100+ let versions_path = path. join ( PYTHON_VERSIONS_FILENAME ) ;
101+
102+ let paths = match options. preference {
103+ FilePreference :: Versions => [ versions_path, version_path] ,
104+ FilePreference :: Version => [ version_path, versions_path] ,
49105 } ;
50- for path in paths {
51- if let Some ( result) = Self :: try_from_path ( path) . await ? {
52- return Ok ( Some ( result) ) ;
53- } ;
54- }
55106
56- Ok ( None )
107+ paths . into_iter ( ) . find ( |path| path . is_file ( ) )
57108 }
58109
59110 /// Try to read a Python version file at the given path.
@@ -62,7 +113,10 @@ impl PythonVersionFile {
62113 pub async fn try_from_path ( path : PathBuf ) -> Result < Option < Self > , std:: io:: Error > {
63114 match fs:: tokio:: read_to_string ( & path) . await {
64115 Ok ( content) => {
65- debug ! ( "Reading requests from `{}`" , path. display( ) ) ;
116+ debug ! (
117+ "Reading Python requests from version file at `{}`" ,
118+ path. display( )
119+ ) ;
66120 let versions = content
67121 . lines ( )
68122 . filter ( |line| {
@@ -104,7 +158,7 @@ impl PythonVersionFile {
104158 }
105159 }
106160
107- /// Return the first version declared in the file, if any.
161+ /// Return the first request declared in the file, if any.
108162 pub fn version ( & self ) -> Option < & PythonRequest > {
109163 self . versions . first ( )
110164 }
0 commit comments