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 .filter(|line| {
197 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 pub fn new(path: PathBuf) -> Self {
227 Self {
228 path,
229 versions: vec![],
230 }
231 }
232
233 pub fn global() -> Option<Self> {
237 let path = user_uv_config_dir()?.join(PYTHON_VERSION_FILENAME);
238 Some(Self::new(path))
239 }
240
241 pub fn is_global(&self) -> bool {
243 Self::global().is_some_and(|global| self.path() == global.path())
244 }
245
246 pub fn version(&self) -> Option<&PythonRequest> {
248 self.versions.first()
249 }
250
251 pub fn versions(&self) -> impl Iterator<Item = &PythonRequest> {
253 self.versions.iter()
254 }
255
256 pub fn into_versions(self) -> Vec<PythonRequest> {
258 self.versions
259 }
260
261 pub fn into_version(self) -> Option<PythonRequest> {
263 self.versions.into_iter().next()
264 }
265
266 pub fn path(&self) -> &Path {
268 &self.path
269 }
270
271 pub fn file_name(&self) -> &str {
274 self.path.file_name().unwrap().to_str().unwrap()
275 }
276
277 #[must_use]
279 pub fn with_versions(self, versions: Vec<PythonRequest>) -> Self {
280 Self {
281 path: self.path,
282 versions,
283 }
284 }
285
286 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}