Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/uv-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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
107 changes: 82 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,91 @@ 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>,
/// 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.
/// 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 +116,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 +161,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
17 changes: 13 additions & 4 deletions crates/uv-scripts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,19 @@ impl Pep723Item {
Self::Remote(metadata) => metadata,
}
}

/// Return the path of the PEP 723 item, if any.
pub fn path(&self) -> Option<&Path> {
match self {
Self::Script(script) => Some(&script.path),
Self::Stdin(_) => None,
Self::Remote(_) => None,
}
}
}

/// A PEP 723 script, including its [`Pep723Metadata`].
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct Pep723Script {
/// The path to the Python script.
pub path: PathBuf,
Expand Down Expand Up @@ -188,7 +197,7 @@ impl Pep723Script {
/// PEP 723 metadata as parsed from a `script` comment block.
///
/// See: <https://peps.python.org/pep-0723/>
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct Pep723Metadata {
pub dependencies: Option<Vec<uv_pep508::Requirement<VerbatimParsedUrl>>>,
Expand Down Expand Up @@ -248,13 +257,13 @@ impl FromStr for Pep723Metadata {
}
}

#[derive(Deserialize, Debug)]
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case")]
pub struct Tool {
pub uv: Option<ToolUv>,
}

#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Clone)]
#[serde(deny_unknown_fields)]
pub struct ToolUv {
#[serde(flatten)]
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 @@ -925,6 +925,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 @@ -1127,6 +1128,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)
})
Comment on lines 1129 to 1134
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code isn't used in production, but this updates the behavior in tandem with copying the implementation for PythonVersionFile::discover to solve a common footgun where you pass the project root as the place to stop discovery and discovery stopped before looking in that directory.

Expand Down Expand Up @@ -1219,6 +1221,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 @@ -1385,6 +1388,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_frontend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,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 @@ -391,9 +392,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
56 changes: 30 additions & 26 deletions crates/uv/src/commands/project/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ use uv_pep508::{ExtraName, Requirement, UnnamedRequirement, VersionOrUrl};
use uv_pypi_types::{redact_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, InstallTarget};
use uv_scripts::Pep723Script;
use uv_scripts::{Pep723Item, Pep723Script};
use uv_types::{BuildIsolation, HashStrategy};
use uv_warnings::warn_user_once;
use uv_workspace::pyproject::{DependencyType, Source, SourceError};
Expand All @@ -44,7 +44,9 @@ use crate::commands::pip::loggers::{
use crate::commands::pip::operations::Modifications;
use crate::commands::pip::resolution_environment;
use crate::commands::project::lock::LockMode;
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 @@ -76,6 +78,7 @@ pub(crate) async fn add(
concurrency: Concurrency,
native_tls: bool,
allow_insecure_host: &[TrustedHost],
no_config: bool,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
Expand Down Expand Up @@ -134,12 +137,13 @@ 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,
python_preference,
python_downloads,
no_config,
&client_builder,
cache,
&reporter,
Expand All @@ -148,28 +152,17 @@ 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,
&Pep723Item::Script(script.clone()),
Copy link
Member Author

@zanieb zanieb Oct 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Annoyingly, I don't see a way to avoid this clone unless we make PEP723Item accept a borrowed script or create an into_script -> Option<...> method which we'd unwrap later. I think since it's in add it's fine to clone.

no_config,
)
.await?;

let interpreter = PythonInstallation::find_or_download(
python_request.as_ref(),
Expand All @@ -183,6 +176,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 Expand Up @@ -221,6 +224,7 @@ pub(crate) async fn add(
connectivity,
native_tls,
allow_insecure_host,
no_config,
cache,
printer,
)
Expand Down
3 changes: 3 additions & 0 deletions crates/uv/src/commands/project/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub(crate) async fn export(
concurrency: Concurrency,
native_tls: bool,
allow_insecure_host: &[TrustedHost],
no_config: bool,
quiet: bool,
cache: &Cache,
printer: Printer,
Expand Down Expand Up @@ -99,12 +100,14 @@ pub(crate) async fn export(
// Find an interpreter for the project
interpreter = ProjectInterpreter::discover(
project.workspace(),
project_dir,
python.as_deref().map(PythonRequest::parse),
python_preference,
python_downloads,
connectivity,
native_tls,
allow_insecure_host,
no_config,
cache,
printer,
)
Expand Down
Loading
Loading