Skip to main content

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