use std::ops::Add; use std::path::{Path, PathBuf}; use fs_err as fs; use itertools::Itertools; use tracing::debug; use uv_dirs::user_uv_config_dir; use uv_fs::Simplified; use uv_warnings::warn_user_once; use crate::PythonRequest; /// The file name for Python version pins. pub static PYTHON_VERSION_FILENAME: &str = ".python-version"; /// The file name for multiple Python version declarations. pub static PYTHON_VERSIONS_FILENAME: &str = ".python-versions"; /// A `.python-version` or `.python-versions` file. #[derive(Debug, Clone)] pub struct PythonVersionFile { /// The path to the version file. path: PathBuf, /// The Python version requests declared in the file. versions: Vec, } /// Whether to prefer the `.python-version` or `.python-versions` file. #[derive(Debug, Clone, Copy, Default)] pub enum FilePreference { #[default] Version, Versions, } #[derive(Debug, Default, Clone)] pub struct DiscoveryOptions<'a> { /// The path to stop discovery at. stop_discovery_at: Option<&'a Path>, /// When `no_config` is set, Python version files will be ignored. /// /// Discovery will still run in order to display a log about the ignored file. no_config: bool, preference: FilePreference, } impl<'a> DiscoveryOptions<'a> { #[must_use] pub fn with_no_config(self, no_config: bool) -> Self { Self { no_config, ..self } } #[must_use] pub fn with_preference(self, preference: FilePreference) -> Self { Self { preference, ..self } } #[must_use] pub fn with_stop_discovery_at(self, stop_discovery_at: Option<&'a Path>) -> Self { Self { stop_discovery_at, ..self } } } impl PythonVersionFile { /// Find a Python version file in the given directory or any of its parents. pub async fn discover( working_directory: impl AsRef, options: &DiscoveryOptions<'_>, ) -> Result, std::io::Error> { let Some(path) = Self::find_nearest(&working_directory, options) else { if let Some(stop_discovery_at) = options.stop_discovery_at { if stop_discovery_at == working_directory.as_ref() { debug!( "No Python version file found in workspace: {}", working_directory.as_ref().display() ); } else { debug!( "No Python version file found between working directory `{}` and workspace root `{}`", working_directory.as_ref().display(), stop_discovery_at.display() ); } } else { debug!( "No Python version file found in ancestors of working directory: {}", working_directory.as_ref().display() ); } // Not found in directory or its ancestors. Looking in user-level config. return Ok(match user_uv_config_dir() { Some(user_dir) => Self::discover_user_config(user_dir, options) .await? .or(None), None => None, }); }; if options.no_config { debug!( "Ignoring Python version file at `{}` due to `--no-config`", path.user_display() ); return Ok(None); } // Uses `try_from_path` instead of `from_path` to avoid TOCTOU failures. Self::try_from_path(path).await } pub async fn discover_user_config( user_config_working_directory: impl AsRef, options: &DiscoveryOptions<'_>, ) -> Result, std::io::Error> { if !options.no_config { if let Some(path) = Self::find_in_directory(user_config_working_directory.as_ref(), options) .into_iter() .find(|path| path.is_file()) { return Self::try_from_path(path).await; } } Ok(None) } fn find_nearest(path: impl AsRef, options: &DiscoveryOptions<'_>) -> Option { path.as_ref() .ancestors() .take_while(|path| { // Only walk up the given directory, if any. options .stop_discovery_at .and_then(Path::parent) .map(|stop_discovery_at| stop_discovery_at != *path) .unwrap_or(true) }) .find_map(|path| Self::find_in_directory(path, options)) } fn find_in_directory(path: &Path, options: &DiscoveryOptions<'_>) -> Option { let version_path = path.join(PYTHON_VERSION_FILENAME); let versions_path = path.join(PYTHON_VERSIONS_FILENAME); let paths = match options.preference { FilePreference::Versions => [versions_path, version_path], FilePreference::Version => [version_path, versions_path], }; paths.into_iter().find(|path| path.is_file()) } /// Try to read a Python version file at the given path. /// /// If the file does not exist, `Ok(None)` is returned. pub async fn try_from_path(path: PathBuf) -> Result, std::io::Error> { match fs::tokio::read_to_string(&path).await { Ok(content) => { debug!( "Reading Python requests from version file at `{}`", path.display() ); let versions = content .lines() .filter(|line| { // Skip comments and empty lines. let trimmed = line.trim(); !(trimmed.is_empty() || trimmed.starts_with('#')) }) .map(ToString::to_string) .map(|version| PythonRequest::parse(&version)) .filter(|request| { if let PythonRequest::ExecutableName(name) = request { warn_user_once!( "Ignoring unsupported Python request `{name}` in version file: {}", path.display() ); false } else { true } }) .collect(); Ok(Some(Self { path, versions })) } Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(err) => Err(err), } } /// Read a Python version file at the given path. /// /// If the file does not exist, an error is returned. pub async fn from_path(path: PathBuf) -> Result { let Some(result) = Self::try_from_path(path).await? else { return Err(std::io::Error::new( std::io::ErrorKind::NotFound, "Version file not found".to_string(), )); }; Ok(result) } /// Create a new representation of a version file at the given path. /// /// The file will not any include versions; see [`PythonVersionFile::with_versions`]. /// The file will not be created; see [`PythonVersionFile::write`]. pub fn new(path: PathBuf) -> Self { Self { path, versions: vec![], } } /// Return the first request declared in the file, if any. pub fn version(&self) -> Option<&PythonRequest> { self.versions.first() } /// Iterate of all versions declared in the file. pub fn versions(&self) -> impl Iterator { self.versions.iter() } /// Cast to a list of all versions declared in the file. pub fn into_versions(self) -> Vec { self.versions } /// Cast to the first version declared in the file, if any. pub fn into_version(self) -> Option { self.versions.into_iter().next() } /// Return the path to the version file. pub fn path(&self) -> &Path { &self.path } /// Return the file name of the version file (guaranteed to be one of `.python-version` or /// `.python-versions`). pub fn file_name(&self) -> &str { self.path.file_name().unwrap().to_str().unwrap() } /// Set the versions for the file. #[must_use] pub fn with_versions(self, versions: Vec) -> Self { Self { path: self.path, versions, } } /// Update the version file on the file system. pub async fn write(&self) -> Result<(), std::io::Error> { debug!("Writing Python versions to `{}`", self.path.display()); fs::tokio::write( &self.path, self.versions .iter() .map(PythonRequest::to_canonical_string) .join("\n") .add("\n") .as_bytes(), ) .await } }