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 .is_none_or(|stop_discovery_at| stop_discovery_at != *path)
141 })
142 .find_map(|path| Self::find_in_directory(path, options))
143 }
144
145 fn find_in_directory(path: &Path, options: &DiscoveryOptions<'_>) -> Option<PathBuf> {
146 let version_path = path.join(PYTHON_VERSION_FILENAME);
147 let versions_path = path.join(PYTHON_VERSIONS_FILENAME);
148
149 let paths = match options.preference {
150 FilePreference::Versions => [versions_path, version_path],
151 FilePreference::Version => [version_path, versions_path],
152 };
153
154 paths.into_iter().find(|path| path.is_file())
155 }
156
157 async fn try_from_path(path: PathBuf) -> Result<Option<Self>, std::io::Error> {
161 match fs::tokio::read_to_string(&path).await {
162 Ok(content) => {
163 debug!(
164 "Reading Python requests from version file at `{}`",
165 path.display()
166 );
167 let versions = content
168 .lines()
169 .filter(|line| {
170 let trimmed = line.trim();
172 !(trimmed.is_empty() || trimmed.starts_with('#'))
173 })
174 .map(ToString::to_string)
175 .map(|version| PythonRequest::parse(&version))
176 .filter(|request| {
177 if let PythonRequest::ExecutableName(name) = request {
178 warn_user_once!(
179 "Ignoring unsupported Python request `{name}` in version file: {}",
180 path.display()
181 );
182 false
183 } else {
184 true
185 }
186 })
187 .collect();
188 Ok(Some(Self { path, versions }))
189 }
190 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
191 Err(err) => Err(err),
192 }
193 }
194
195 pub fn new(path: PathBuf) -> Self {
200 Self {
201 path,
202 versions: vec![],
203 }
204 }
205
206 pub fn global() -> Option<Self> {
210 let path = user_uv_config_dir()?.join(PYTHON_VERSION_FILENAME);
211 Some(Self::new(path))
212 }
213
214 pub fn is_global(&self) -> bool {
216 Self::global().is_some_and(|global| self.path() == global.path())
217 }
218
219 pub fn version(&self) -> Option<&PythonRequest> {
221 self.versions.first()
222 }
223
224 pub fn versions(&self) -> impl Iterator<Item = &PythonRequest> {
226 self.versions.iter()
227 }
228
229 pub fn into_versions(self) -> Vec<PythonRequest> {
231 self.versions
232 }
233
234 pub fn into_version(self) -> Option<PythonRequest> {
236 self.versions.into_iter().next()
237 }
238
239 pub fn path(&self) -> &Path {
241 &self.path
242 }
243
244 pub fn file_name(&self) -> &str {
247 self.path.file_name().unwrap().to_str().unwrap()
248 }
249
250 #[must_use]
252 pub fn with_versions(self, versions: Vec<PythonRequest>) -> Self {
253 Self {
254 path: self.path,
255 versions,
256 }
257 }
258
259 pub async fn write(&self) -> Result<(), std::io::Error> {
261 debug!("Writing Python versions to `{}`", self.path.display());
262 if let Some(parent) = self.path.parent() {
263 fs_err::tokio::create_dir_all(parent).await?;
264 }
265 fs::tokio::write(
266 &self.path,
267 self.versions
268 .iter()
269 .map(PythonRequest::to_canonical_string)
270 .join("\n")
271 .add("\n")
272 .as_bytes(),
273 )
274 .await
275 }
276}