uv_python/
version_files.rs1use 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
13pub static PYTHON_VERSION_FILENAME: &str = ".python-version";
15
16pub static PYTHON_VERSIONS_FILENAME: &str = ".python-versions";
18
19#[derive(Debug, Clone)]
21pub struct PythonVersionFile {
22 path: PathBuf,
24 versions: Vec<PythonRequest>,
26}
27
28#[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 stop_discovery_at: Option<&'a Path>,
40 no_config: bool,
44 preference: FilePreference,
46 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 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 let local = Self::find_nearest(&working_directory, options);
85 if local.is_none() {
86 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 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 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 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 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 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 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 pub fn new(path: PathBuf) -> Self {
214 Self {
215 path,
216 versions: vec![],
217 }
218 }
219
220 pub fn global() -> Option<Self> {
224 let path = user_uv_config_dir()?.join(PYTHON_VERSION_FILENAME);
225 Some(Self::new(path))
226 }
227
228 pub fn is_global(&self) -> bool {
230 Self::global().is_some_and(|global| self.path() == global.path())
231 }
232
233 pub fn version(&self) -> Option<&PythonRequest> {
235 self.versions.first()
236 }
237
238 pub fn versions(&self) -> impl Iterator<Item = &PythonRequest> {
240 self.versions.iter()
241 }
242
243 pub fn into_versions(self) -> Vec<PythonRequest> {
245 self.versions
246 }
247
248 pub fn into_version(self) -> Option<PythonRequest> {
250 self.versions.into_iter().next()
251 }
252
253 pub fn path(&self) -> &Path {
255 &self.path
256 }
257
258 pub fn file_name(&self) -> &str {
261 self.path.file_name().unwrap().to_str().unwrap()
262 }
263
264 #[must_use]
266 pub fn with_versions(self, versions: Vec<PythonRequest>) -> Self {
267 Self {
268 path: self.path,
269 versions,
270 }
271 }
272
273 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}