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 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 fn new(path: PathBuf) -> Self {
201 Self {
202 path,
203 versions: vec![],
204 }
205 }
206
207 pub fn global() -> Option<Self> {
211 let path = user_uv_config_dir()?.join(PYTHON_VERSION_FILENAME);
212 Some(Self::new(path))
213 }
214
215 pub fn is_global(&self) -> bool {
217 Self::global().is_some_and(|global| self.path() == global.path())
218 }
219
220 pub fn version(&self) -> Option<&PythonRequest> {
222 self.versions.first()
223 }
224
225 pub fn versions(&self) -> impl Iterator<Item = &PythonRequest> {
227 self.versions.iter()
228 }
229
230 pub fn into_versions(self) -> Vec<PythonRequest> {
232 self.versions
233 }
234
235 pub fn into_version(self) -> Option<PythonRequest> {
237 self.versions.into_iter().next()
238 }
239
240 pub fn path(&self) -> &Path {
242 &self.path
243 }
244
245 pub fn file_name(&self) -> &str {
248 self.path.file_name().unwrap().to_str().unwrap()
249 }
250
251 #[must_use]
253 pub fn with_versions(self, versions: Vec<PythonRequest>) -> Self {
254 Self {
255 path: self.path,
256 versions,
257 }
258 }
259
260 pub async fn write(&self) -> Result<(), std::io::Error> {
262 debug!("Writing Python versions to `{}`", self.path.display());
263 if let Some(parent) = self.path.parent() {
264 fs_err::tokio::create_dir_all(parent).await?;
265 }
266 fs::tokio::write(
267 &self.path,
268 self.versions
269 .iter()
270 .map(PythonRequest::to_canonical_string)
271 .join("\n")
272 .add("\n")
273 .as_bytes(),
274 )
275 .await
276 }
277}