uv_python/
version_files.rs

1use std::ops::Add;
2use std::path::{Path, PathBuf};
3
4use fs_err as fs;
5use itertools::Itertools;
6use tracing::debug;
7use uv_dirs::user_uv_config_dir;
8use uv_fs::Simplified;
9use uv_warnings::warn_user_once;
10
11use crate::PythonRequest;
12
13/// The file name for Python version pins.
14pub static PYTHON_VERSION_FILENAME: &str = ".python-version";
15
16/// The file name for multiple Python version declarations.
17pub static PYTHON_VERSIONS_FILENAME: &str = ".python-versions";
18
19/// A `.python-version` or `.python-versions` file.
20#[derive(Debug, Clone)]
21pub struct PythonVersionFile {
22    /// The path to the version file.
23    path: PathBuf,
24    /// The Python version requests declared in the file.
25    versions: Vec<PythonRequest>,
26}
27
28/// Whether to prefer the `.python-version` or `.python-versions` file.
29#[derive(Debug, Clone, Copy, Default)]
30pub enum FilePreference {
31    #[default]
32    Version,
33    Versions,
34}
35
36#[derive(Debug, Default, Clone)]
37pub struct DiscoveryOptions<'a> {
38    /// The path to stop discovery at.
39    stop_discovery_at: Option<&'a Path>,
40    /// Ignore Python version files.
41    ///
42    /// Discovery will still run in order to display a log about the ignored file.
43    no_config: bool,
44    /// Whether `.python-version` or `.python-versions` should be preferred.
45    preference: FilePreference,
46    /// Whether to ignore local version files, and only search for a global one.
47    no_local: bool,
48}
49
50impl<'a> DiscoveryOptions<'a> {
51    #[must_use]
52    pub fn with_no_config(self, no_config: bool) -> Self {
53        Self { no_config, ..self }
54    }
55
56    #[must_use]
57    pub fn with_preference(self, preference: FilePreference) -> Self {
58        Self { preference, ..self }
59    }
60
61    #[must_use]
62    pub fn with_stop_discovery_at(self, stop_discovery_at: Option<&'a Path>) -> Self {
63        Self {
64            stop_discovery_at,
65            ..self
66        }
67    }
68
69    #[must_use]
70    pub fn with_no_local(self, no_local: bool) -> Self {
71        Self { no_local, ..self }
72    }
73}
74
75impl PythonVersionFile {
76    /// Find a Python version file in the given directory or any of its parents.
77    pub async fn discover(
78        working_directory: impl AsRef<Path>,
79        options: &DiscoveryOptions<'_>,
80    ) -> Result<Option<Self>, std::io::Error> {
81        let allow_local = !options.no_local;
82        let Some(path) = allow_local.then(|| {
83            // First, try to find a local version file.
84            let local = Self::find_nearest(&working_directory, options);
85            if local.is_none() {
86                // Log where we searched for the file, if not found
87                if let Some(stop_discovery_at) = options.stop_discovery_at {
88                    if stop_discovery_at == working_directory.as_ref() {
89                        debug!(
90                            "No Python version file found in workspace: {}",
91                            working_directory.as_ref().display()
92                        );
93                    } else {
94                        debug!(
95                            "No Python version file found between working directory `{}` and workspace root `{}`",
96                            working_directory.as_ref().display(),
97                            stop_discovery_at.display()
98                        );
99                    }
100                } else {
101                    debug!(
102                        "No Python version file found in ancestors of working directory: {}",
103                        working_directory.as_ref().display()
104                    );
105                }
106            }
107            local
108        }).flatten().or_else(|| {
109            // Search for a global config
110            Self::find_global(options)
111        }) else {
112            return Ok(None);
113        };
114
115        if options.no_config {
116            debug!(
117                "Ignoring Python version file at `{}` due to `--no-config`",
118                path.user_display()
119            );
120            return Ok(None);
121        }
122
123        // Uses `try_from_path` instead of `from_path` to avoid TOCTOU failures.
124        Self::try_from_path(path).await
125    }
126
127    fn find_global(options: &DiscoveryOptions<'_>) -> Option<PathBuf> {
128        let user_config_dir = user_uv_config_dir()?;
129        Self::find_in_directory(&user_config_dir, options)
130    }
131
132    fn find_nearest(path: impl AsRef<Path>, options: &DiscoveryOptions<'_>) -> Option<PathBuf> {
133        path.as_ref()
134            .ancestors()
135            .take_while(|path| {
136                // Only walk up the given directory, if any.
137                options
138                    .stop_discovery_at
139                    .and_then(Path::parent)
140                    .map(|stop_discovery_at| stop_discovery_at != *path)
141                    .unwrap_or(true)
142            })
143            .find_map(|path| Self::find_in_directory(path, options))
144    }
145
146    fn find_in_directory(path: &Path, options: &DiscoveryOptions<'_>) -> Option<PathBuf> {
147        let version_path = path.join(PYTHON_VERSION_FILENAME);
148        let versions_path = path.join(PYTHON_VERSIONS_FILENAME);
149
150        let paths = match options.preference {
151            FilePreference::Versions => [versions_path, version_path],
152            FilePreference::Version => [version_path, versions_path],
153        };
154
155        paths.into_iter().find(|path| path.is_file())
156    }
157
158    /// Try to read a Python version file at the given path.
159    ///
160    /// If the file does not exist, `Ok(None)` is returned.
161    pub async fn try_from_path(path: PathBuf) -> Result<Option<Self>, std::io::Error> {
162        match fs::tokio::read_to_string(&path).await {
163            Ok(content) => {
164                debug!(
165                    "Reading Python requests from version file at `{}`",
166                    path.display()
167                );
168                let versions = content
169                    .lines()
170                    .filter(|line| {
171                        // Skip comments and empty lines.
172                        let trimmed = line.trim();
173                        !(trimmed.is_empty() || trimmed.starts_with('#'))
174                    })
175                    .map(ToString::to_string)
176                    .map(|version| PythonRequest::parse(&version))
177                    .filter(|request| {
178                        if let PythonRequest::ExecutableName(name) = request {
179                            warn_user_once!(
180                                "Ignoring unsupported Python request `{name}` in version file: {}",
181                                path.display()
182                            );
183                            false
184                        } else {
185                            true
186                        }
187                    })
188                    .collect();
189                Ok(Some(Self { path, versions }))
190            }
191            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
192            Err(err) => Err(err),
193        }
194    }
195
196    /// Read a Python version file at the given path.
197    ///
198    /// If the file does not exist, an error is returned.
199    pub async fn from_path(path: PathBuf) -> Result<Self, std::io::Error> {
200        let Some(result) = Self::try_from_path(path).await? else {
201            return Err(std::io::Error::new(
202                std::io::ErrorKind::NotFound,
203                "Version file not found".to_string(),
204            ));
205        };
206        Ok(result)
207    }
208
209    /// Create a new representation of a version file at the given path.
210    ///
211    /// The file will not any include versions; see [`PythonVersionFile::with_versions`].
212    /// The file will not be created; see [`PythonVersionFile::write`].
213    pub fn new(path: PathBuf) -> Self {
214        Self {
215            path,
216            versions: vec![],
217        }
218    }
219
220    /// Create a new representation of a global Python version file.
221    ///
222    /// Returns [`None`] if the user configuration directory cannot be determined.
223    pub fn global() -> Option<Self> {
224        let path = user_uv_config_dir()?.join(PYTHON_VERSION_FILENAME);
225        Some(Self::new(path))
226    }
227
228    /// Returns `true` if the version file is a global version file.
229    pub fn is_global(&self) -> bool {
230        Self::global().is_some_and(|global| self.path() == global.path())
231    }
232
233    /// Return the first request declared in the file, if any.
234    pub fn version(&self) -> Option<&PythonRequest> {
235        self.versions.first()
236    }
237
238    /// Iterate of all versions declared in the file.
239    pub fn versions(&self) -> impl Iterator<Item = &PythonRequest> {
240        self.versions.iter()
241    }
242
243    /// Cast to a list of all versions declared in the file.
244    pub fn into_versions(self) -> Vec<PythonRequest> {
245        self.versions
246    }
247
248    /// Cast to the first version declared in the file, if any.
249    pub fn into_version(self) -> Option<PythonRequest> {
250        self.versions.into_iter().next()
251    }
252
253    /// Return the path to the version file.
254    pub fn path(&self) -> &Path {
255        &self.path
256    }
257
258    /// Return the file name of the version file (guaranteed to be one of `.python-version` or
259    /// `.python-versions`).
260    pub fn file_name(&self) -> &str {
261        self.path.file_name().unwrap().to_str().unwrap()
262    }
263
264    /// Set the versions for the file.
265    #[must_use]
266    pub fn with_versions(self, versions: Vec<PythonRequest>) -> Self {
267        Self {
268            path: self.path,
269            versions,
270        }
271    }
272
273    /// Update the version file on the file system.
274    pub async fn write(&self) -> Result<(), std::io::Error> {
275        debug!("Writing Python versions to `{}`", self.path.display());
276        if let Some(parent) = self.path.parent() {
277            fs_err::tokio::create_dir_all(parent).await?;
278        }
279        fs::tokio::write(
280            &self.path,
281            self.versions
282                .iter()
283                .map(PythonRequest::to_canonical_string)
284                .join("\n")
285                .add("\n")
286                .as_bytes(),
287        )
288        .await
289    }
290}