Skip to content

Commit

Permalink
Discover and respect .python-version files in parent directories
Browse files Browse the repository at this point in the history
  • Loading branch information
zanieb committed Oct 3, 2024
1 parent 891c91d commit 984c0dc
Show file tree
Hide file tree
Showing 19 changed files with 653 additions and 239 deletions.
1 change: 1 addition & 0 deletions crates/uv-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub use crate::prefix::Prefix;
pub use crate::python_version::PythonVersion;
pub use crate::target::Target;
pub use crate::version_files::{
DiscoveryOptions as VersionFileDiscoveryOptions, FilePreference as VersionFilePreference,
PythonVersionFile, PYTHON_VERSIONS_FILENAME, PYTHON_VERSION_FILENAME,
};
pub use crate::virtualenv::{Error as VirtualEnvError, PyVenvConfiguration, VirtualEnvironment};
Expand Down
104 changes: 79 additions & 25 deletions crates/uv-python/src/version_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf};
use fs_err as fs;
use itertools::Itertools;
use tracing::debug;
use uv_fs::Simplified;

use crate::PythonRequest;

Expand All @@ -22,38 +23,88 @@ pub struct PythonVersionFile {
versions: Vec<PythonRequest>,
}

/// 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>,
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.
/// Find a Python version file in the given directory or any of its parents.
pub async fn discover(
working_directory: impl AsRef<Path>,
// TODO(zanieb): Create a `DiscoverySettings` struct for these options
no_config: bool,
prefer_versions: bool,
options: &DiscoveryOptions<'_>,
) -> Result<Option<Self>, 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`");
};
let Some(path) = Self::find_nearest(working_directory, options) else {
return Ok(None);
};

if options.no_config {
debug!(
"Ignoring Python version file at `{}` due to `--no-config`",
path.user_display()
);
return Ok(None);
}

let paths = if prefer_versions {
[versions_path, version_path]
} else {
[version_path, versions_path]
// Uses `try_from_path` instead of `from_path` to avoid TOCTOU failures.
Self::try_from_path(path).await
}

fn find_nearest(path: impl AsRef<Path>, options: &DiscoveryOptions<'_>) -> Option<PathBuf> {
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<PathBuf> {
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],
};
for path in paths {
if let Some(result) = Self::try_from_path(path).await? {
return Ok(Some(result));
};
}

Ok(None)
paths.into_iter().find(|path| path.is_file())
}

/// Try to read a Python version file at the given path.
Expand All @@ -62,7 +113,10 @@ impl PythonVersionFile {
pub async fn try_from_path(path: PathBuf) -> Result<Option<Self>, std::io::Error> {
match fs::tokio::read_to_string(&path).await {
Ok(content) => {
debug!("Reading requests from `{}`", path.display());
debug!(
"Reading Python requests from version file at `{}`",
path.display()
);
let versions = content
.lines()
.filter(|line| {
Expand Down Expand Up @@ -104,7 +158,7 @@ impl PythonVersionFile {
}
}

/// Return the first version declared in the file, if any.
/// Return the first request declared in the file, if any.
pub fn version(&self) -> Option<&PythonRequest> {
self.versions.first()
}
Expand Down
4 changes: 4 additions & 0 deletions crates/uv-workspace/src/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,7 @@ impl ProjectWorkspace {
// 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)
})
Expand Down Expand Up @@ -1074,6 +1075,7 @@ async fn find_workspace(
// 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)
})
Expand Down Expand Up @@ -1166,6 +1168,7 @@ pub fn check_nested_workspaces(inner_workspace_root: &Path, options: &DiscoveryO
// 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)
})
Expand Down Expand Up @@ -1332,6 +1335,7 @@ impl VirtualProject {
// 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)
})
Expand Down
12 changes: 8 additions & 4 deletions crates/uv/src/commands/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ use uv_fs::Simplified;
use uv_normalize::PackageName;
use uv_python::{
EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionRequest,
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions,
VersionRequest,
};
use uv_requirements::RequirementsSource;
use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython};
Expand Down Expand Up @@ -366,9 +367,12 @@ async fn build_package(

// (2) Request from `.python-version`
if interpreter_request.is_none() {
interpreter_request = PythonVersionFile::discover(source.directory(), no_config, false)
.await?
.and_then(PythonVersionFile::into_version);
interpreter_request = PythonVersionFile::discover(
source.directory(),
&VersionFileDiscoveryOptions::default().with_no_config(no_config),
)
.await?
.and_then(PythonVersionFile::into_version);
}

// (3) `Requires-Python` in `pyproject.toml`
Expand Down
46 changes: 21 additions & 25 deletions crates/uv/src/commands/project/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use uv_pep508::{ExtraName, Requirement, UnnamedRequirement, VersionOrUrl};
use uv_pypi_types::{redact_git_credentials, ParsedUrl, RequirementSource, VerbatimParsedUrl};
use uv_python::{
EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionRequest,
PythonPreference, PythonRequest,
};
use uv_requirements::{NamedRequirementsResolver, RequirementsSource, RequirementsSpecification};
use uv_resolver::FlatIndex;
Expand All @@ -41,7 +41,9 @@ use crate::commands::pip::loggers::{
};
use crate::commands::pip::operations::Modifications;
use crate::commands::pip::resolution_environment;
use crate::commands::project::{script_python_requirement, ProjectError};
use crate::commands::project::{
init_script_python_requirement, validate_script_requires_python, ProjectError, ScriptPython,
};
use crate::commands::reporters::{PythonDownloadReporter, ResolverReporter};
use crate::commands::{diagnostics, pip, project, ExitStatus, SharedState};
use crate::printer::Printer;
Expand Down Expand Up @@ -128,7 +130,7 @@ pub(crate) async fn add(
let script = if let Some(script) = Pep723Script::read(&script).await? {
script
} else {
let requires_python = script_python_requirement(
let requires_python = init_script_python_requirement(
python.as_deref(),
project_dir,
false,
Expand All @@ -142,28 +144,12 @@ pub(crate) async fn add(
Pep723Script::init(&script, requires_python.specifiers()).await?
};

let python_request = if let Some(request) = python.as_deref() {
// (1) Explicit request from user
Some(PythonRequest::parse(request))
} else if let Some(request) = PythonVersionFile::discover(project_dir, false, false)
.await?
.and_then(PythonVersionFile::into_version)
{
// (2) Request from `.python-version`
Some(request)
} else {
// (3) `Requires-Python` in `pyproject.toml`
script
.metadata
.requires_python
.clone()
.map(|requires_python| {
PythonRequest::Version(VersionRequest::Range(
requires_python,
PythonVariant::Default,
))
})
};
let ScriptPython {
source,
python_request,
requires_python,
} = ScriptPython::from_request(python.as_deref().map(PythonRequest::parse), None, &script)
.await?;

let interpreter = PythonInstallation::find_or_download(
python_request.as_ref(),
Expand All @@ -177,6 +163,16 @@ pub(crate) async fn add(
.await?
.into_interpreter();

if let Some((requires_python, requires_python_source)) = requires_python {
validate_script_requires_python(
&interpreter,
None,
&requires_python,
&requires_python_source,
&source,
)?;
}

Target::Script(script, Box::new(interpreter))
} else {
// Find the project in the workspace.
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/commands/project/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ pub(crate) async fn export(
// Find an interpreter for the project
let interpreter = ProjectInterpreter::discover(
project.workspace(),
project_dir,
python.as_deref().map(PythonRequest::parse),
python_preference,
python_downloads,
Expand Down
10 changes: 5 additions & 5 deletions crates/uv/src/commands/project/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ use uv_pep440::Version;
use uv_pep508::PackageName;
use uv_python::{
EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest,
PythonVariant, PythonVersionFile, VersionRequest,
PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions, VersionRequest,
};
use uv_resolver::RequiresPython;
use uv_scripts::{Pep723Script, ScriptTag};
use uv_warnings::warn_user_once;
use uv_workspace::pyproject_mut::{DependencyTarget, PyProjectTomlMut};
use uv_workspace::{DiscoveryOptions, MemberDiscovery, Workspace, WorkspaceError};

use crate::commands::project::{find_requires_python, script_python_requirement};
use crate::commands::project::{find_requires_python, init_script_python_requirement};
use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::ExitStatus;
use crate::printer::Printer;
Expand Down Expand Up @@ -207,7 +207,7 @@ async fn init_script(
}
};

let requires_python = script_python_requirement(
let requires_python = init_script_python_requirement(
python.as_deref(),
&CWD,
no_pin_python,
Expand Down Expand Up @@ -659,7 +659,7 @@ impl InitProjectKind {

// Write .python-version if it doesn't exist.
if let Some(python_request) = python_request {
if PythonVersionFile::discover(path, false, false)
if PythonVersionFile::discover(path, &VersionFileDiscoveryOptions::default())
.await?
.is_none()
{
Expand Down Expand Up @@ -724,7 +724,7 @@ impl InitProjectKind {

// Write .python-version if it doesn't exist.
if let Some(python_request) = python_request {
if PythonVersionFile::discover(path, false, false)
if PythonVersionFile::discover(path, &VersionFileDiscoveryOptions::default())
.await?
.is_none()
{
Expand Down
1 change: 1 addition & 0 deletions crates/uv/src/commands/project/lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ pub(crate) async fn lock(
// Find an interpreter for the project
let interpreter = ProjectInterpreter::discover(
&workspace,
project_dir,
python.as_deref().map(PythonRequest::parse),
python_preference,
python_downloads,
Expand Down
Loading

0 comments on commit 984c0dc

Please sign in to comment.