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, Clone, Copy, Default, PartialEq, Eq)]
38pub enum ConfigDiscovery {
39 #[default]
40 Enabled,
41 Disabled,
42}
43
44impl ConfigDiscovery {
45 pub fn from_args(no_config: bool) -> Self {
47 if no_config {
48 Self::Disabled
49 } else {
50 Self::Enabled
51 }
52 }
53
54 pub const fn enabled(self) -> bool {
56 matches!(self, Self::Enabled)
57 }
58}
59
60#[derive(Debug, Default, Clone)]
61pub struct DiscoveryOptions<'a> {
62 stop_discovery_at: Option<&'a Path>,
64 config_discovery: ConfigDiscovery,
68 preference: FilePreference,
70 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 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 let local = Self::find_nearest(&working_directory, options);
112 if local.is_none() {
113 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 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 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 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 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 .map(str::trim)
197 .filter(|line| {
198 !(line.is_empty() || line.starts_with('#'))
200 })
201 .map(PythonRequest::parse)
202 .filter(|request| {
203 if let PythonRequest::ExecutableName(name) = request {
204 warn_user_once!(
205 "Ignoring unsupported Python request `{name}` in version file: {}",
206 path.display()
207 );
208 false
209 } else {
210 true
211 }
212 })
213 .collect();
214 Ok(Some(Self { path, versions }))
215 }
216 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
217 Err(err) => Err(err),
218 }
219 }
220
221 pub fn new(path: PathBuf) -> Self {
226 Self {
227 path,
228 versions: vec![],
229 }
230 }
231
232 pub fn global() -> Option<Self> {
236 let path = user_uv_config_dir()?.join(PYTHON_VERSION_FILENAME);
237 Some(Self::new(path))
238 }
239
240 pub fn is_global(&self) -> bool {
242 user_uv_config_dir().is_some_and(|directory| {
243 self.path() == directory.join(PYTHON_VERSION_FILENAME)
244 || self.path() == directory.join(PYTHON_VERSIONS_FILENAME)
245 })
246 }
247
248 pub fn version(&self) -> Option<&PythonRequest> {
250 self.versions.first()
251 }
252
253 pub fn versions(&self) -> impl Iterator<Item = &PythonRequest> {
255 self.versions.iter()
256 }
257
258 pub fn into_versions(self) -> Vec<PythonRequest> {
260 self.versions
261 }
262
263 pub fn into_version(self) -> Option<PythonRequest> {
265 self.versions.into_iter().next()
266 }
267
268 pub fn path(&self) -> &Path {
270 &self.path
271 }
272
273 pub fn file_name(&self) -> &str {
276 self.path.file_name().unwrap().to_str().unwrap()
277 }
278
279 #[must_use]
281 pub fn with_versions(self, versions: Vec<PythonRequest>) -> Self {
282 Self {
283 path: self.path,
284 versions,
285 }
286 }
287
288 pub async fn write(&self) -> Result<(), std::io::Error> {
290 debug!("Writing Python versions to `{}`", self.path.display());
291 if let Some(parent) = self.path.parent() {
292 fs_err::tokio::create_dir_all(parent).await?;
293 }
294 fs::tokio::write(
295 &self.path,
296 self.versions
297 .iter()
298 .map(PythonRequest::to_canonical_string)
299 .join("\n")
300 .add("\n")
301 .as_bytes(),
302 )
303 .await
304 }
305}