Refactor .python-version discovery (#6359)

In preparation for more comprehensive discovery
This commit is contained in:
Zanie Blue 2024-08-21 16:41:20 -05:00 committed by GitHub
parent 1377c6807d
commit 2fbe12ee1b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 186 additions and 133 deletions

View file

@ -14,8 +14,7 @@ pub use crate::prefix::Prefix;
pub use crate::python_version::PythonVersion; pub use crate::python_version::PythonVersion;
pub use crate::target::Target; pub use crate::target::Target;
pub use crate::version_files::{ pub use crate::version_files::{
request_from_version_file, requests_from_version_file, write_version_file, PythonVersionFile, PYTHON_VERSIONS_FILENAME, PYTHON_VERSION_FILENAME,
PYTHON_VERSIONS_FILENAME, PYTHON_VERSION_FILENAME,
}; };
pub use crate::virtualenv::{Error as VirtualEnvError, PyVenvConfiguration, VirtualEnvironment}; pub use crate::virtualenv::{Error as VirtualEnvError, PyVenvConfiguration, VirtualEnvironment};

View file

@ -1,6 +1,7 @@
use std::path::Path; use std::path::{Path, PathBuf};
use fs_err as fs; use fs_err as fs;
use itertools::Itertools;
use tracing::debug; use tracing::debug;
use crate::PythonRequest; use crate::PythonRequest;
@ -11,63 +12,51 @@ pub static PYTHON_VERSION_FILENAME: &str = ".python-version";
/// The file name for multiple Python version declarations. /// The file name for multiple Python version declarations.
pub static PYTHON_VERSIONS_FILENAME: &str = ".python-versions"; pub static PYTHON_VERSIONS_FILENAME: &str = ".python-versions";
/// Read [`PythonRequest`]s from a version file, if present. /// A `.python-version` or `.python-versions` file.
/// #[derive(Debug, Clone)]
/// Prefers `.python-versions` then `.python-version`. pub struct PythonVersionFile {
/// If only one Python version is desired, use [`request_from_version_files`] which prefers the `.python-version` file. /// The path to the version file.
pub async fn requests_from_version_file( path: PathBuf,
directory: &Path, /// The Python version requests declared in the file.
) -> Result<Option<Vec<PythonRequest>>, std::io::Error> { versions: Vec<PythonRequest>,
if let Some(versions) = read_versions_file(directory).await? { }
Ok(Some(
versions impl PythonVersionFile {
.into_iter() /// Find a Python version file in the given directory.
.map(|version| PythonRequest::parse(&version)) pub async fn discover(
.collect(), working_directory: impl AsRef<Path>,
)) no_config: bool,
} else if let Some(version) = read_version_file(directory).await? { ) -> Result<Option<Self>, std::io::Error> {
Ok(Some(vec![PythonRequest::parse(&version)])) let versions_path = working_directory.as_ref().join(PYTHON_VERSIONS_FILENAME);
} else { 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) Ok(None)
} }
}
/// Read a [`PythonRequest`] from a version file, if present. /// Try to read a Python version file at the given path.
/// ///
/// Find the version file inside directory, or the current directory /// If the file does not exist, `Ok(None)` is returned.
/// if None. pub async fn try_from_path(path: PathBuf) -> Result<Option<Self>, std::io::Error> {
/// match fs::tokio::read_to_string(&path).await {
/// Prefers `.python-version` then the first entry of `.python-versions`. Ok(content) => {
/// If multiple Python versions are desired, use [`requests_from_version_files`] instead. debug!("Reading requests from `{}`", path.display());
pub async fn request_from_version_file( let versions = content
directory: &Path,
) -> Result<Option<PythonRequest>, 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<Option<Vec<String>>, 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
.lines() .lines()
.filter(|line| { .filter(|line| {
// Skip comments and empty lines. // Skip comments and empty lines.
@ -75,29 +64,84 @@ async fn read_versions_file(directory: &Path) -> Result<Option<Vec<String>>, std
!(trimmed.is_empty() || trimmed.starts_with('#')) !(trimmed.is_empty() || trimmed.starts_with('#'))
}) })
.map(ToString::to_string) .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<Option<String>, std::io::Error> { /// Read a Python version file at the given path.
let path = directory.join(PYTHON_VERSION_FILENAME); ///
match fs::tokio::read_to_string(&path).await { /// If the file does not exist, an error is returned.
Ok(content) => { pub async fn from_path(path: PathBuf) -> Result<Self, std::io::Error> {
debug!("Reading requests from `{}`", path.display()); let Some(result) = Self::try_from_path(path).await? else {
Ok(content return Err(std::io::Error::new(
.lines() std::io::ErrorKind::NotFound,
.find(|line| { "Version file not found".to_string(),
// Skip comments and empty lines. ));
let trimmed = line.trim(); };
!(trimmed.is_empty() || trimmed.starts_with('#')) Ok(result)
}) }
.map(ToString::to_string))
/// 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<Item = &PythonRequest> {
self.versions.iter()
}
/// Cast to a list of all versions declared in the file.
pub fn into_versions(self) -> Vec<PythonRequest> {
self.versions
}
/// Cast to the first version declared in the file, if any.
pub fn into_version(self) -> Option<PythonRequest> {
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<PythonRequest>) -> 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
} }
} }

View file

@ -20,8 +20,8 @@ use uv_fs::{Simplified, CWD};
use uv_git::GIT_STORE; use uv_git::GIT_STORE;
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_python::{ use uv_python::{
request_from_version_file, EnvironmentPreference, Interpreter, PythonDownloads, EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, VersionRequest, PythonPreference, PythonRequest, PythonVersionFile, VersionRequest,
}; };
use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification}; use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification};
use uv_resolver::{FlatIndex, RequiresPython}; use uv_resolver::{FlatIndex, RequiresPython};
@ -125,7 +125,10 @@ pub(crate) async fn add(
let python_request = if let Some(request) = python.as_deref() { let python_request = if let Some(request) = python.as_deref() {
// (1) Explicit request from user // (1) Explicit request from user
PythonRequest::parse(request) 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` // (2) Request from `.python-version`
request request
} else { } else {
@ -153,7 +156,10 @@ pub(crate) async fn add(
let python_request = if let Some(request) = python.as_deref() { let python_request = if let Some(request) = python.as_deref() {
// (1) Explicit request from user // (1) Explicit request from user
Some(PythonRequest::parse(request)) 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` // (2) Request from `.python-version`
Some(request) Some(request)
} else { } else {

View file

@ -18,8 +18,8 @@ use uv_fs::Simplified;
use uv_installer::{SatisfiesResult, SitePackages}; use uv_installer::{SatisfiesResult, SitePackages};
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_python::{ use uv_python::{
request_from_version_file, EnvironmentPreference, Interpreter, PythonDownloads, EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, VersionRequest, PythonPreference, PythonRequest, PythonVersionFile, VersionRequest,
}; };
use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification}; use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification};
use uv_resolver::{ use uv_resolver::{
@ -177,7 +177,10 @@ impl WorkspacePython {
let python_request = if let Some(request) = python_request { let python_request = if let Some(request) = python_request {
Some(request) Some(request)
// (2) Request from `.python-version` // (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) Some(request)
// (3) `Requires-Python` in `pyproject.toml` // (3) `Requires-Python` in `pyproject.toml`
} else { } else {

View file

@ -20,8 +20,8 @@ use uv_fs::{PythonExt, Simplified, CWD};
use uv_installer::{SatisfiesResult, SitePackages}; use uv_installer::{SatisfiesResult, SitePackages};
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_python::{ use uv_python::{
request_from_version_file, EnvironmentPreference, Interpreter, PythonDownloads, EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, VersionRequest, PythonPreference, PythonRequest, PythonVersionFile, VersionRequest,
}; };
use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_scripts::{Pep723Error, Pep723Script}; use uv_scripts::{Pep723Error, Pep723Script};
@ -109,7 +109,10 @@ pub(crate) async fn run(
let python_request = if let Some(request) = python.as_deref() { let python_request = if let Some(request) = python.as_deref() {
Some(PythonRequest::parse(request)) Some(PythonRequest::parse(request))
// (2) Request from `.python-version` // (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) Some(request)
// (3) `Requires-Python` in `pyproject.toml` // (3) `Requires-Python` in `pyproject.toml`
} else { } else {

View file

@ -1,6 +1,5 @@
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::fmt::Write; use std::fmt::Write;
use std::path::PathBuf;
use anyhow::Result; use anyhow::Result;
use fs_err as fs; use fs_err as fs;
@ -8,16 +7,12 @@ use futures::stream::FuturesUnordered;
use futures::StreamExt; use futures::StreamExt;
use itertools::Itertools; use itertools::Itertools;
use owo_colors::OwoColorize; use owo_colors::OwoColorize;
use tracing::debug;
use uv_client::Connectivity; use uv_client::Connectivity;
use uv_fs::CWD; use uv_fs::CWD;
use uv_python::downloads::{DownloadResult, ManagedPythonDownload, PythonDownloadRequest}; use uv_python::downloads::{DownloadResult, ManagedPythonDownload, PythonDownloadRequest};
use uv_python::managed::{ManagedPythonInstallation, ManagedPythonInstallations}; use uv_python::managed::{ManagedPythonInstallation, ManagedPythonInstallations};
use uv_python::{ use uv_python::{PythonDownloads, PythonRequest, PythonVersionFile};
requests_from_version_file, PythonDownloads, PythonRequest, PYTHON_VERSIONS_FILENAME,
PYTHON_VERSION_FILENAME,
};
use crate::commands::python::{ChangeEvent, ChangeEventKind}; use crate::commands::python::{ChangeEvent, ChangeEventKind};
use crate::commands::reporters::PythonDownloadReporter; use crate::commands::reporters::PythonDownloadReporter;
@ -43,18 +38,10 @@ pub(crate) async fn install(
let targets = targets.into_iter().collect::<BTreeSet<_>>(); let targets = targets.into_iter().collect::<BTreeSet<_>>();
let requests: Vec<_> = if targets.is_empty() { let requests: Vec<_> = if targets.is_empty() {
// Read from the version file, unless `--no-config` was requested PythonVersionFile::discover(&*CWD, no_config)
let version_file_requests = if no_config { .await?
if PathBuf::from(PYTHON_VERSION_FILENAME).exists() { .map(uv_python::PythonVersionFile::into_versions)
debug!("Ignoring `.python-version` file due to `--no-config`"); .unwrap_or_else(|| vec![PythonRequest::Any])
} 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])
} else { } else {
targets targets
.iter() .iter()

View file

@ -8,8 +8,7 @@ use tracing::debug;
use uv_cache::Cache; use uv_cache::Cache;
use uv_fs::{Simplified, CWD}; use uv_fs::{Simplified, CWD};
use uv_python::{ use uv_python::{
request_from_version_file, requests_from_version_file, write_version_file, EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile,
EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest,
PYTHON_VERSION_FILENAME, PYTHON_VERSION_FILENAME,
}; };
use uv_warnings::warn_user_once; 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 { let Some(request) = request else {
// Display the current pinned Python version // Display the current pinned Python version
if let Some(pins) = requests_from_version_file(&CWD).await? { if let Some(file) = version_file? {
for pin in pins { for pin in file.versions() {
writeln!(printer.stdout(), "{}", pin.to_canonical_string())?; writeln!(printer.stdout(), "{}", pin.to_canonical_string())?;
if let Some(virtual_project) = &virtual_project { if let Some(virtual_project) = &virtual_project {
warn_if_existing_pin_incompatible_with_project( warn_if_existing_pin_incompatible_with_project(
&pin, pin,
virtual_project, virtual_project,
python_preference, python_preference,
cache, 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` // SAFETY: We exit early if Python is not found and resolved is `true`
python // TODO(zanieb): Maybe avoid reparsing here?
.unwrap() PythonRequest::parse(
.interpreter() &python
.sys_executable() .unwrap()
.user_display() .interpreter()
.to_string() .sys_executable()
.user_display()
.to_string(),
)
} else { } else {
request.to_canonical_string() request
}; };
let existing = request_from_version_file(&CWD).await.ok().flatten(); let existing = version_file.ok().flatten();
write_version_file(&output).await?; // 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 if let Some(existing) = existing
.map(|existing| existing.to_canonical_string()) .as_ref()
.filter(|existing| existing != &output) .and_then(PythonVersionFile::version)
.filter(|version| *version != new.version().unwrap())
{ {
writeln!( writeln!(
printer.stdout(), printer.stdout(),
"Updated `{}` from `{}` -> `{}`", "Updated `{}` from `{}` -> `{}`",
PYTHON_VERSION_FILENAME.cyan(), new.path().user_display().cyan(),
existing.green(), existing.to_canonical_string().green(),
output.green() new.version().unwrap().to_canonical_string().green()
)?; )?;
} else { } else {
writeln!( writeln!(
printer.stdout(), printer.stdout(),
"Pinned `{}` to `{}`", "Pinned `{}` to `{}`",
PYTHON_VERSION_FILENAME.cyan(), new.path().user_display().cyan(),
output.green() new.version().unwrap().to_canonical_string().green()
)?; )?;
} }

View file

@ -22,8 +22,8 @@ use uv_configuration::{
use uv_dispatch::BuildDispatch; use uv_dispatch::BuildDispatch;
use uv_fs::{Simplified, CWD}; use uv_fs::{Simplified, CWD};
use uv_python::{ use uv_python::{
request_from_version_file, EnvironmentPreference, PythonDownloads, PythonInstallation, EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest,
PythonPreference, PythonRequest, VersionRequest, PythonVersionFile, VersionRequest,
}; };
use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython}; use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython};
use uv_shell::Shell; use uv_shell::Shell;
@ -145,10 +145,11 @@ async fn venv_impl(
// (2) Request from `.python-version` // (2) Request from `.python-version`
if interpreter_request.is_none() { if interpreter_request.is_none() {
interpreter_request = // TODO(zanieb): Support `--no-config` here
request_from_version_file(&std::env::current_dir().into_diagnostic()?) interpreter_request = PythonVersionFile::discover(&*CWD, false)
.await .await
.into_diagnostic()?; .into_diagnostic()?
.and_then(PythonVersionFile::into_version);
} }
// (3) `Requires-Python` in `pyproject.toml` // (3) `Requires-Python` in `pyproject.toml`

View file

@ -649,6 +649,7 @@ fn python_pin_with_comments() -> Result<()> {
exit_code: 0 exit_code: 0
----- stdout ----- ----- stdout -----
3.12 3.12
3.10
----- stderr ----- ----- stderr -----
"###); "###);