diff --git a/crates/uv-python/src/lib.rs b/crates/uv-python/src/lib.rs index b259994aa..9f8652b2c 100644 --- a/crates/uv-python/src/lib.rs +++ b/crates/uv-python/src/lib.rs @@ -14,8 +14,7 @@ pub use crate::prefix::Prefix; pub use crate::python_version::PythonVersion; pub use crate::target::Target; pub use crate::version_files::{ - request_from_version_file, requests_from_version_file, write_version_file, - PYTHON_VERSIONS_FILENAME, PYTHON_VERSION_FILENAME, + PythonVersionFile, PYTHON_VERSIONS_FILENAME, PYTHON_VERSION_FILENAME, }; pub use crate::virtualenv::{Error as VirtualEnvError, PyVenvConfiguration, VirtualEnvironment}; diff --git a/crates/uv-python/src/version_files.rs b/crates/uv-python/src/version_files.rs index ba794b7bc..c592e1433 100644 --- a/crates/uv-python/src/version_files.rs +++ b/crates/uv-python/src/version_files.rs @@ -1,6 +1,7 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use fs_err as fs; +use itertools::Itertools; use tracing::debug; use crate::PythonRequest; @@ -11,63 +12,51 @@ pub static PYTHON_VERSION_FILENAME: &str = ".python-version"; /// The file name for multiple Python version declarations. pub static PYTHON_VERSIONS_FILENAME: &str = ".python-versions"; -/// Read [`PythonRequest`]s from a version file, if present. -/// -/// Prefers `.python-versions` then `.python-version`. -/// If only one Python version is desired, use [`request_from_version_files`] which prefers the `.python-version` file. -pub async fn requests_from_version_file( - directory: &Path, -) -> Result>, std::io::Error> { - if let Some(versions) = read_versions_file(directory).await? { - Ok(Some( - versions - .into_iter() - .map(|version| PythonRequest::parse(&version)) - .collect(), - )) - } else if let Some(version) = read_version_file(directory).await? { - Ok(Some(vec![PythonRequest::parse(&version)])) - } else { +/// 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, +} + +impl PythonVersionFile { + /// Find a Python version file in the given directory. + pub async fn discover( + working_directory: impl AsRef, + no_config: bool, + ) -> Result, std::io::Error> { + let versions_path = working_directory.as_ref().join(PYTHON_VERSIONS_FILENAME); + let version_path = working_directory.as_ref().join(PYTHON_VERSION_FILENAME); + + if no_config { + if version_path.exists() { + debug!("Ignoring `.python-version` file due to `--no-config`"); + } else if versions_path.exists() { + debug!("Ignoring `.python-versions` file due to `--no-config`"); + }; + return Ok(None); + } + + if let Some(result) = Self::try_from_path(version_path).await? { + return Ok(Some(result)); + }; + if let Some(result) = Self::try_from_path(versions_path).await? { + return Ok(Some(result)); + }; + Ok(None) } -} -/// Read a [`PythonRequest`] from a version file, if present. -/// -/// Find the version file inside directory, or the current directory -/// if None. -/// -/// Prefers `.python-version` then the first entry of `.python-versions`. -/// If multiple Python versions are desired, use [`requests_from_version_files`] instead. -pub async fn request_from_version_file( - directory: &Path, -) -> Result, std::io::Error> { - if let Some(version) = read_version_file(directory).await? { - Ok(Some(PythonRequest::parse(&version))) - } else if let Some(versions) = read_versions_file(directory).await? { - Ok(versions - .into_iter() - .next() - .inspect(|_| debug!("Using the first version from `{PYTHON_VERSIONS_FILENAME}`")) - .map(|version| PythonRequest::parse(&version))) - } else { - Ok(None) - } -} - -/// Write a version to a .`python-version` file. -pub async fn write_version_file(version: &str) -> Result<(), std::io::Error> { - debug!("Writing Python version `{version}` to `{PYTHON_VERSION_FILENAME}`"); - fs::tokio::write(PYTHON_VERSION_FILENAME, format!("{version}\n")).await -} - -async fn read_versions_file(directory: &Path) -> Result>, std::io::Error> { - let path = directory.join(PYTHON_VERSIONS_FILENAME); - match fs::tokio::read_to_string(&path).await { - Ok(content) => { - debug!("Reading requests from `{}`", path.display()); - Ok(Some( - content + /// 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 requests from `{}`", path.display()); + let versions = content .lines() .filter(|line| { // Skip comments and empty lines. @@ -75,29 +64,84 @@ async fn read_versions_file(directory: &Path) -> Result>, std !(trimmed.is_empty() || trimmed.starts_with('#')) }) .map(ToString::to_string) - .collect(), - )) + .map(|version| PythonRequest::parse(&version)) + .collect(); + Ok(Some(Self { path, versions })) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(err), } -} -async fn read_version_file(directory: &Path) -> Result, std::io::Error> { - let path = directory.join(PYTHON_VERSION_FILENAME); - match fs::tokio::read_to_string(&path).await { - Ok(content) => { - debug!("Reading requests from `{}`", path.display()); - Ok(content - .lines() - .find(|line| { - // Skip comments and empty lines. - let trimmed = line.trim(); - !(trimmed.is_empty() || trimmed.starts_with('#')) - }) - .map(ToString::to_string)) + /// 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 versions; see [`PythonVersionFile::with_versions`]. + /// The file will not be created; see [`PythonVersionFile::write`]. + pub fn new(path: PathBuf) -> Self { + Self { + path, + versions: vec![], } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(err) => Err(err), + } + + /// Return the first version 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 + } + + /// 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") + .as_bytes(), + ) + .await } } diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index a721703c1..1383f32b1 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -20,8 +20,8 @@ use uv_fs::{Simplified, CWD}; use uv_git::GIT_STORE; use uv_normalize::PackageName; use uv_python::{ - request_from_version_file, EnvironmentPreference, Interpreter, PythonDownloads, - PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, VersionRequest, + EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation, + PythonPreference, PythonRequest, PythonVersionFile, VersionRequest, }; use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification}; use uv_resolver::{FlatIndex, RequiresPython}; @@ -125,7 +125,10 @@ pub(crate) async fn add( let python_request = if let Some(request) = python.as_deref() { // (1) Explicit request from user PythonRequest::parse(request) - } else if let Some(request) = request_from_version_file(&CWD).await? { + } else if let Some(request) = PythonVersionFile::discover(&*CWD, false) + .await? + .and_then(PythonVersionFile::into_version) + { // (2) Request from `.python-version` request } else { @@ -153,7 +156,10 @@ pub(crate) async fn add( let python_request = if let Some(request) = python.as_deref() { // (1) Explicit request from user Some(PythonRequest::parse(request)) - } else if let Some(request) = request_from_version_file(&CWD).await? { + } else if let Some(request) = PythonVersionFile::discover(&*CWD, false) + .await? + .and_then(PythonVersionFile::into_version) + { // (2) Request from `.python-version` Some(request) } else { diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 802dd343b..9e8f1329b 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -18,8 +18,8 @@ use uv_fs::Simplified; use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_python::{ - request_from_version_file, EnvironmentPreference, Interpreter, PythonDownloads, - PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, VersionRequest, + EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation, + PythonPreference, PythonRequest, PythonVersionFile, VersionRequest, }; use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification}; use uv_resolver::{ @@ -177,7 +177,10 @@ impl WorkspacePython { let python_request = if let Some(request) = python_request { Some(request) // (2) Request from `.python-version` - } else if let Some(request) = request_from_version_file(workspace.install_path()).await? { + } else if let Some(request) = PythonVersionFile::discover(workspace.install_path(), false) + .await? + .and_then(PythonVersionFile::into_version) + { Some(request) // (3) `Requires-Python` in `pyproject.toml` } else { diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 1f2a77bb0..041c2df86 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -20,8 +20,8 @@ use uv_fs::{PythonExt, Simplified, CWD}; use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::PackageName; use uv_python::{ - request_from_version_file, EnvironmentPreference, Interpreter, PythonDownloads, - PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, VersionRequest, + EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation, + PythonPreference, PythonRequest, PythonVersionFile, VersionRequest, }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_scripts::{Pep723Error, Pep723Script}; @@ -109,7 +109,10 @@ pub(crate) async fn run( let python_request = if let Some(request) = python.as_deref() { Some(PythonRequest::parse(request)) // (2) Request from `.python-version` - } else if let Some(request) = request_from_version_file(&CWD).await? { + } else if let Some(request) = PythonVersionFile::discover(&*CWD, false) + .await? + .and_then(PythonVersionFile::into_version) + { Some(request) // (3) `Requires-Python` in `pyproject.toml` } else { diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index 6f221a666..74ddad19a 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -1,6 +1,5 @@ use std::collections::BTreeSet; use std::fmt::Write; -use std::path::PathBuf; use anyhow::Result; use fs_err as fs; @@ -8,16 +7,12 @@ use futures::stream::FuturesUnordered; use futures::StreamExt; use itertools::Itertools; use owo_colors::OwoColorize; -use tracing::debug; use uv_client::Connectivity; use uv_fs::CWD; use uv_python::downloads::{DownloadResult, ManagedPythonDownload, PythonDownloadRequest}; use uv_python::managed::{ManagedPythonInstallation, ManagedPythonInstallations}; -use uv_python::{ - requests_from_version_file, PythonDownloads, PythonRequest, PYTHON_VERSIONS_FILENAME, - PYTHON_VERSION_FILENAME, -}; +use uv_python::{PythonDownloads, PythonRequest, PythonVersionFile}; use crate::commands::python::{ChangeEvent, ChangeEventKind}; use crate::commands::reporters::PythonDownloadReporter; @@ -43,18 +38,10 @@ pub(crate) async fn install( let targets = targets.into_iter().collect::>(); let requests: Vec<_> = if targets.is_empty() { - // Read from the version file, unless `--no-config` was requested - let version_file_requests = if no_config { - if PathBuf::from(PYTHON_VERSION_FILENAME).exists() { - debug!("Ignoring `.python-version` file due to `--no-config`"); - } else if PathBuf::from(PYTHON_VERSIONS_FILENAME).exists() { - debug!("Ignoring `.python-versions` file due to `--no-config`"); - } - None - } else { - requests_from_version_file(&CWD).await? - }; - version_file_requests.unwrap_or_else(|| vec![PythonRequest::Any]) + PythonVersionFile::discover(&*CWD, no_config) + .await? + .map(uv_python::PythonVersionFile::into_versions) + .unwrap_or_else(|| vec![PythonRequest::Any]) } else { targets .iter() diff --git a/crates/uv/src/commands/python/pin.rs b/crates/uv/src/commands/python/pin.rs index f85e4acab..df509fcdd 100644 --- a/crates/uv/src/commands/python/pin.rs +++ b/crates/uv/src/commands/python/pin.rs @@ -8,8 +8,7 @@ use tracing::debug; use uv_cache::Cache; use uv_fs::{Simplified, CWD}; use uv_python::{ - request_from_version_file, requests_from_version_file, write_version_file, - EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest, + EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile, PYTHON_VERSION_FILENAME, }; use uv_warnings::warn_user_once; @@ -39,14 +38,16 @@ pub(crate) async fn pin( } }; + let version_file = PythonVersionFile::discover(&*CWD, false).await; + let Some(request) = request else { // Display the current pinned Python version - if let Some(pins) = requests_from_version_file(&CWD).await? { - for pin in pins { + if let Some(file) = version_file? { + for pin in file.versions() { writeln!(printer.stdout(), "{}", pin.to_canonical_string())?; if let Some(virtual_project) = &virtual_project { warn_if_existing_pin_incompatible_with_project( - &pin, + pin, virtual_project, python_preference, cache, @@ -106,38 +107,46 @@ pub(crate) async fn pin( }; } - let output = if resolved { + let request = if resolved { // SAFETY: We exit early if Python is not found and resolved is `true` - python - .unwrap() - .interpreter() - .sys_executable() - .user_display() - .to_string() + // TODO(zanieb): Maybe avoid reparsing here? + PythonRequest::parse( + &python + .unwrap() + .interpreter() + .sys_executable() + .user_display() + .to_string(), + ) } else { - request.to_canonical_string() + request }; - let existing = request_from_version_file(&CWD).await.ok().flatten(); - write_version_file(&output).await?; + let existing = version_file.ok().flatten(); + // TODO(zanieb): Allow updating the discovered version file with an `--update` flag. + let new = + PythonVersionFile::new(CWD.join(PYTHON_VERSION_FILENAME)).with_versions(vec![request]); + + new.write().await?; if let Some(existing) = existing - .map(|existing| existing.to_canonical_string()) - .filter(|existing| existing != &output) + .as_ref() + .and_then(PythonVersionFile::version) + .filter(|version| *version != new.version().unwrap()) { writeln!( printer.stdout(), "Updated `{}` from `{}` -> `{}`", - PYTHON_VERSION_FILENAME.cyan(), - existing.green(), - output.green() + new.path().user_display().cyan(), + existing.to_canonical_string().green(), + new.version().unwrap().to_canonical_string().green() )?; } else { writeln!( printer.stdout(), "Pinned `{}` to `{}`", - PYTHON_VERSION_FILENAME.cyan(), - output.green() + new.path().user_display().cyan(), + new.version().unwrap().to_canonical_string().green() )?; } diff --git a/crates/uv/src/commands/venv.rs b/crates/uv/src/commands/venv.rs index 78de5e0a5..d5f05622c 100644 --- a/crates/uv/src/commands/venv.rs +++ b/crates/uv/src/commands/venv.rs @@ -22,8 +22,8 @@ use uv_configuration::{ use uv_dispatch::BuildDispatch; use uv_fs::{Simplified, CWD}; use uv_python::{ - request_from_version_file, EnvironmentPreference, PythonDownloads, PythonInstallation, - PythonPreference, PythonRequest, VersionRequest, + EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest, + PythonVersionFile, VersionRequest, }; use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython}; use uv_shell::Shell; @@ -145,10 +145,11 @@ async fn venv_impl( // (2) Request from `.python-version` if interpreter_request.is_none() { - interpreter_request = - request_from_version_file(&std::env::current_dir().into_diagnostic()?) - .await - .into_diagnostic()?; + // TODO(zanieb): Support `--no-config` here + interpreter_request = PythonVersionFile::discover(&*CWD, false) + .await + .into_diagnostic()? + .and_then(PythonVersionFile::into_version); } // (3) `Requires-Python` in `pyproject.toml` diff --git a/crates/uv/tests/python_pin.rs b/crates/uv/tests/python_pin.rs index 3a08dc173..c1485a637 100644 --- a/crates/uv/tests/python_pin.rs +++ b/crates/uv/tests/python_pin.rs @@ -649,6 +649,7 @@ fn python_pin_with_comments() -> Result<()> { exit_code: 0 ----- stdout ----- 3.12 + 3.10 ----- stderr ----- "###);