Discover and respect .python-version files in parent directories (#6370)

Uses #6369 for test coverage.

Updates version file discovery to search up into parent directories.
Also refactors Python request determination to avoid duplicating the
user request / version file / workspace lookup logic in every command
(this supersedes the work started in
https://github.com/astral-sh/uv/pull/6372).

There is a bit of remaining work here, mostly around documentation.
There are some edge-cases where we don't use the refactored request
utility, like `uv build` — I'm not sure how I'm going to handle that yet
as it needs a separate root directory.
This commit is contained in:
Zanie Blue 2024-11-04 13:48:13 -06:00
parent fb89b64acf
commit 8ef5949294
23 changed files with 807 additions and 252 deletions

View file

@ -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};

View file

@ -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;
@ -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);
let Some(path) = Self::find_nearest(working_directory, options) else {
return Ok(None);
};
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`");
};
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]
};
for path in paths {
if let Some(result) = Self::try_from_path(path).await? {
return Ok(Some(result));
};
}
// Uses `try_from_path` instead of `from_path` to avoid TOCTOU failures.
Self::try_from_path(path).await
}
Ok(None)
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],
};
paths.into_iter().find(|path| path.is_file())
}
/// Try to read a Python version file at the given path.
@ -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| {
@ -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()
}

View file

@ -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,
@ -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>>>,
@ -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)]

View file

@ -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)
})
@ -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)
})
@ -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)
})
@ -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)
})

View file

@ -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};
@ -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`

View file

@ -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};
@ -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;
@ -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> {
@ -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,
@ -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()),
no_config,
)
.await?;
let interpreter = PythonInstallation::find_or_download(
python_request.as_ref(),
@ -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.
@ -221,6 +224,7 @@ pub(crate) async fn add(
connectivity,
native_tls,
allow_insecure_host,
no_config,
cache,
printer,
)

View file

@ -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,
@ -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,
)

View file

@ -18,7 +18,7 @@ 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};
@ -26,7 +26,7 @@ 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;
@ -51,6 +51,7 @@ pub(crate) async fn init(
connectivity: Connectivity,
native_tls: bool,
allow_insecure_host: &[TrustedHost],
no_config: bool,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
@ -75,6 +76,7 @@ pub(crate) async fn init(
package,
native_tls,
allow_insecure_host,
no_config,
)
.await?;
@ -131,6 +133,7 @@ pub(crate) async fn init(
connectivity,
native_tls,
allow_insecure_host,
no_config,
cache,
printer,
)
@ -183,6 +186,7 @@ async fn init_script(
package: bool,
native_tls: bool,
allow_insecure_host: &[TrustedHost],
no_config: bool,
) -> Result<()> {
if no_workspace {
warn_user_once!("`--no-workspace` is a no-op for Python scripts, which are standalone");
@ -226,12 +230,13 @@ async fn init_script(
}
};
let requires_python = script_python_requirement(
let requires_python = init_script_python_requirement(
python.as_deref(),
&CWD,
no_pin_python,
python_preference,
python_downloads,
no_config,
&client_builder,
cache,
&reporter,
@ -266,6 +271,7 @@ async fn init_project(
connectivity: Connectivity,
native_tls: bool,
allow_insecure_host: &[TrustedHost],
no_config: bool,
cache: &Cache,
printer: Printer,
) -> Result<()> {
@ -318,10 +324,35 @@ async fn init_project(
.native_tls(native_tls)
.allow_insecure_host(allow_insecure_host.to_vec());
// Add a `requires-python` field to the `pyproject.toml` and return the corresponding interpreter.
let (requires_python, python_request) = if let Some(request) = python.as_deref() {
// First, determine if there is an request for Python
let python_request = if let Some(request) = python {
// (1) Explicit request from user
match PythonRequest::parse(request) {
Some(PythonRequest::parse(&request))
} else if let Some(file) = PythonVersionFile::discover(
path,
&VersionFileDiscoveryOptions::default()
.with_stop_discovery_at(
workspace
.as_ref()
.map(Workspace::install_path)
.map(PathBuf::as_ref),
)
.with_no_config(no_config),
)
.await?
{
// (2) Request from `.python-version`
file.into_version()
} else {
None
};
// Add a `requires-python` field to the `pyproject.toml` and return the corresponding interpreter.
let (requires_python, python_request) = if let Some(python_request) = python_request {
// (1) A request from the user or `.python-version` file
// This can be arbitrary, i.e., not a version — in which case we may need to resolve the
// interpreter
match python_request {
PythonRequest::Version(VersionRequest::MajorMinor(
major,
minor,
@ -427,7 +458,7 @@ async fn init_project(
}
}
} else if let Some(requires_python) = workspace.as_ref().and_then(find_requires_python) {
// (2) `Requires-Python` from the workspace
// (2) `requires-python` from the workspace
let python_request = PythonRequest::Version(VersionRequest::Range(
requires_python.specifiers().clone(),
PythonVariant::Default,
@ -687,7 +718,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()
{
@ -741,7 +772,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()
{

View file

@ -84,6 +84,7 @@ pub(crate) async fn lock(
concurrency: Concurrency,
native_tls: bool,
allow_insecure_host: &[TrustedHost],
no_config: bool,
cache: &Cache,
printer: Printer,
) -> anyhow::Result<ExitStatus> {
@ -98,12 +99,14 @@ pub(crate) async fn lock(
// Find an interpreter for the project
interpreter = ProjectInterpreter::discover(
&workspace,
project_dir,
python.as_deref().map(PythonRequest::parse),
python_preference,
python_downloads,
connectivity,
native_tls,
allow_insecure_host,
no_config,
cache,
printer,
)

View file

@ -16,7 +16,7 @@ use uv_distribution::DistributionDatabase;
use uv_distribution_types::{
Index, Resolution, UnresolvedRequirement, UnresolvedRequirementSpecification,
};
use uv_fs::Simplified;
use uv_fs::{Simplified, CWD};
use uv_git::ResolvedRepositoryReference;
use uv_installer::{SatisfiesResult, SitePackages};
use uv_normalize::{GroupName, PackageName, DEV_DEPENDENCIES};
@ -26,7 +26,7 @@ use uv_pypi_types::Requirement;
use uv_python::{
EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads, PythonEnvironment,
PythonInstallation, PythonPreference, PythonRequest, PythonVariant, PythonVersionFile,
VersionRequest,
VersionFileDiscoveryOptions, VersionRequest,
};
use uv_requirements::upgrade::{read_lock_requirements, LockedRequirements};
use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification};
@ -34,6 +34,7 @@ use uv_resolver::{
FlatIndex, Lock, OptionsBuilder, PythonRequirement, RequiresPython, ResolutionGraph,
ResolverEnvironment,
};
use uv_scripts::Pep723Item;
use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy};
use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::dependency_groups::DependencyGroupError;
@ -89,13 +90,13 @@ pub(crate) enum ProjectError {
RequiresPythonProjectIncompatibility(Version, RequiresPython),
#[error("The requested interpreter resolved to Python {0}, which is incompatible with the script's Python requirement: `{1}`")]
RequestedPythonScriptIncompatibility(Version, VersionSpecifiers),
RequestedPythonScriptIncompatibility(Version, RequiresPython),
#[error("The Python request from `{0}` resolved to Python {1}, which is incompatible with the script's Python requirement: `{2}`")]
DotPythonVersionScriptIncompatibility(String, Version, VersionSpecifiers),
DotPythonVersionScriptIncompatibility(String, Version, RequiresPython),
#[error("The resolved Python interpreter (Python {0}) is incompatible with the script's Python requirement: `{1}`")]
RequiresPythonScriptIncompatibility(Version, VersionSpecifiers),
RequiresPythonScriptIncompatibility(Version, RequiresPython),
#[error("The requested interpreter resolved to Python {0}, which is incompatible with the project's Python requirement: `{1}`. However, a workspace member (`{member}`) supports Python {3}. To install the workspace member on its own, navigate to `{path}`, then run `{venv}` followed by `{install}`.", member = _2.cyan(), venv = format!("uv venv --python {_0}").green(), install = "uv pip install -e .".green(), path = _4.user_display().cyan() )]
RequestedMemberIncompatibility(
@ -222,7 +223,7 @@ pub(crate) fn find_requires_python(workspace: &Workspace) -> Option<RequiresPyth
#[allow(clippy::result_large_err)]
pub(crate) fn validate_requires_python(
interpreter: &Interpreter,
workspace: &Workspace,
workspace: Option<&Workspace>,
requires_python: &RequiresPython,
source: &PythonRequestSource,
) -> Result<(), ProjectError> {
@ -235,7 +236,7 @@ pub(crate) fn validate_requires_python(
// a library in the workspace is compatible with Python >=3.8, the user may attempt
// to sync on Python 3.8. This will fail, but we should provide a more helpful error
// message.
for (name, member) in workspace.packages() {
for (name, member) in workspace.into_iter().flat_map(Workspace::packages) {
let Some(project) = member.pyproject_toml().project.as_ref() else {
continue;
};
@ -255,7 +256,7 @@ pub(crate) fn validate_requires_python(
}
PythonRequestSource::DotPythonVersion(file) => {
Err(ProjectError::DotPythonVersionMemberIncompatibility(
file.to_string(),
file.path().user_display().to_string(),
interpreter.python_version().clone(),
requires_python.clone(),
name.clone(),
@ -285,7 +286,7 @@ pub(crate) fn validate_requires_python(
}
PythonRequestSource::DotPythonVersion(file) => {
Err(ProjectError::DotPythonVersionProjectIncompatibility(
file.to_string(),
file.path().user_display().to_string(),
interpreter.python_version().clone(),
requires_python.clone(),
))
@ -299,6 +300,49 @@ pub(crate) fn validate_requires_python(
}
}
/// Returns an error if the [`Interpreter`] does not satisfy script or workspace `requires-python`.
#[allow(clippy::result_large_err)]
pub(crate) fn validate_script_requires_python(
interpreter: &Interpreter,
workspace: Option<&Workspace>,
requires_python: &RequiresPython,
requires_python_source: &RequiresPythonSource,
request_source: &PythonRequestSource,
) -> Result<(), ProjectError> {
match requires_python_source {
RequiresPythonSource::Project => {
validate_requires_python(interpreter, workspace, requires_python, request_source)?;
}
RequiresPythonSource::Script => {}
};
if requires_python.contains(interpreter.python_version()) {
return Ok(());
}
match request_source {
PythonRequestSource::UserRequest => {
Err(ProjectError::RequestedPythonScriptIncompatibility(
interpreter.python_version().clone(),
requires_python.clone(),
))
}
PythonRequestSource::DotPythonVersion(file) => {
Err(ProjectError::DotPythonVersionScriptIncompatibility(
file.file_name().to_string(),
interpreter.python_version().clone(),
requires_python.clone(),
))
}
PythonRequestSource::RequiresPython => {
Err(ProjectError::RequiresPythonScriptIncompatibility(
interpreter.python_version().clone(),
requires_python.clone(),
))
}
}
}
/// An interpreter suitable for the project.
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
@ -314,47 +358,68 @@ pub(crate) enum PythonRequestSource {
/// The request was provided by the user.
UserRequest,
/// The request was inferred from a `.python-version` or `.python-versions` file.
DotPythonVersion(String),
DotPythonVersion(PythonVersionFile),
/// The request was inferred from a `pyproject.toml` file.
RequiresPython,
}
impl std::fmt::Display for PythonRequestSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PythonRequestSource::UserRequest => write!(f, "explicit request"),
PythonRequestSource::DotPythonVersion(file) => {
write!(f, "version file at `{}`", file.path().user_display())
}
PythonRequestSource::RequiresPython => write!(f, "`requires-python` metadata"),
}
}
}
/// The resolved Python request and requirement for a [`Workspace`].
#[derive(Debug, Clone)]
pub(crate) struct WorkspacePython {
/// The source of the Python request.
source: PythonRequestSource,
pub(crate) source: PythonRequestSource,
/// The resolved Python request, computed by considering (1) any explicit request from the user
/// via `--python`, (2) any implicit request from the user via `.python-version`, and (3) any
/// `Requires-Python` specifier in the `pyproject.toml`.
python_request: Option<PythonRequest>,
pub(crate) python_request: Option<PythonRequest>,
/// The resolved Python requirement for the project, computed by taking the intersection of all
/// `Requires-Python` specifiers in the workspace.
requires_python: Option<RequiresPython>,
pub(crate) requires_python: Option<RequiresPython>,
}
impl WorkspacePython {
/// Determine the [`WorkspacePython`] for the current [`Workspace`].
pub(crate) async fn from_request(
python_request: Option<PythonRequest>,
workspace: &Workspace,
workspace: Option<&Workspace>,
project_dir: &Path,
no_config: bool,
) -> Result<Self, ProjectError> {
let requires_python = find_requires_python(workspace);
let requires_python = workspace.and_then(find_requires_python);
let workspace_root = workspace.map(Workspace::install_path);
let (source, python_request) = if let Some(request) = python_request {
// (1) Explicit request from user
let source = PythonRequestSource::UserRequest;
let request = Some(request);
(source, request)
} else if let Some(file) =
PythonVersionFile::discover(workspace.install_path(), false, false).await?
} else if let Some(file) = PythonVersionFile::discover(
project_dir,
&VersionFileDiscoveryOptions::default()
.with_stop_discovery_at(workspace_root.map(PathBuf::as_ref))
.with_no_config(no_config),
)
.await?
{
// (2) Request from `.python-version`
let source = PythonRequestSource::DotPythonVersion(file.file_name().to_string());
let source = PythonRequestSource::DotPythonVersion(file.clone());
let request = file.into_version();
(source, request)
} else {
// (3) `Requires-Python` in `pyproject.toml`
// (3) `requires-python` in `pyproject.toml`
let request = requires_python
.as_ref()
.map(RequiresPython::specifiers)
@ -368,6 +433,87 @@ impl WorkspacePython {
(source, request)
};
if let Some(python_request) = python_request.as_ref() {
debug!(
"Using Python request `{}` from {source}",
python_request.to_canonical_string()
);
};
Ok(Self {
source,
python_request,
requires_python,
})
}
}
/// The source of a `Requires-Python` specifier.
#[derive(Debug, Clone)]
pub(crate) enum RequiresPythonSource {
/// From the PEP 723 inline script metadata.
Script,
/// From a `pyproject.toml` in a workspace.
Project,
}
/// The resolved Python request and requirement for a [`Pep723Script`]
#[derive(Debug, Clone)]
pub(crate) struct ScriptPython {
/// The source of the Python request.
pub(crate) source: PythonRequestSource,
/// The resolved Python request, computed by considering (1) any explicit request from the user
/// via `--python`, (2) any implicit request from the user via `.python-version`, (3) any
/// `Requires-Python` specifier in the script metadata, and (4) any `Requires-Python` specifier
/// in the `pyproject.toml`.
pub(crate) python_request: Option<PythonRequest>,
/// The resolved Python requirement for the script and its source.
pub(crate) requires_python: Option<(RequiresPython, RequiresPythonSource)>,
}
impl ScriptPython {
/// Determine the [`ScriptPython`] for the current [`Workspace`].
pub(crate) async fn from_request(
python_request: Option<PythonRequest>,
workspace: Option<&Workspace>,
script: &Pep723Item,
no_config: bool,
) -> Result<Self, ProjectError> {
// First, discover a requirement from the workspace
let WorkspacePython {
mut source,
mut python_request,
requires_python,
} = WorkspacePython::from_request(
python_request,
workspace,
script.path().and_then(Path::parent).unwrap_or(&**CWD),
no_config,
)
.await?;
// If the script has a `requires-python` specifier, prefer that over one from the workspace.
let requires_python =
if let Some(requires_python_specifiers) = script.metadata().requires_python.as_ref() {
if python_request.is_none() {
python_request = Some(PythonRequest::Version(VersionRequest::Range(
requires_python_specifiers.clone(),
PythonVariant::Default,
)));
source = PythonRequestSource::RequiresPython;
}
Some((
RequiresPython::from_specifiers(requires_python_specifiers),
RequiresPythonSource::Script,
))
} else {
requires_python.map(|requirement| (requirement, RequiresPythonSource::Project))
};
if let Some(python_request) = python_request.as_ref() {
debug!("Using Python request {python_request} from {source}");
};
Ok(Self {
source,
python_request,
@ -380,12 +526,14 @@ impl ProjectInterpreter {
/// Discover the interpreter to use in the current [`Workspace`].
pub(crate) async fn discover(
workspace: &Workspace,
project_dir: &Path,
python_request: Option<PythonRequest>,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
connectivity: Connectivity,
native_tls: bool,
allow_insecure_host: &[TrustedHost],
no_config: bool,
cache: &Cache,
printer: Printer,
) -> Result<Self, ProjectError> {
@ -394,7 +542,8 @@ impl ProjectInterpreter {
source,
python_request,
requires_python,
} = WorkspacePython::from_request(python_request, workspace).await?;
} = WorkspacePython::from_request(python_request, Some(workspace), project_dir, no_config)
.await?;
// Read from the virtual environment first.
let venv = workspace.venv();
@ -402,11 +551,15 @@ impl ProjectInterpreter {
Ok(venv) => {
if python_request.as_ref().map_or(true, |request| {
if request.satisfied(venv.interpreter(), cache) {
debug!("The virtual environment's Python version satisfies `{request}`");
debug!(
"The virtual environment's Python version satisfies `{}`",
request.to_canonical_string()
);
true
} else {
debug!(
"The virtual environment's Python version does not satisfy `{request}`"
"The virtual environment's Python version does not satisfy `{}`",
request.to_canonical_string()
);
false
}
@ -499,7 +652,7 @@ impl ProjectInterpreter {
}
if let Some(requires_python) = requires_python.as_ref() {
validate_requires_python(&interpreter, workspace, requires_python, &source)?;
validate_requires_python(&interpreter, Some(workspace), requires_python, &source)?;
}
Ok(Self::Interpreter(interpreter))
@ -523,17 +676,20 @@ pub(crate) async fn get_or_init_environment(
connectivity: Connectivity,
native_tls: bool,
allow_insecure_host: &[TrustedHost],
no_config: bool,
cache: &Cache,
printer: Printer,
) -> Result<PythonEnvironment, ProjectError> {
match ProjectInterpreter::discover(
workspace,
workspace.install_path().as_ref(),
python,
python_preference,
python_downloads,
connectivity,
native_tls,
allow_insecure_host,
no_config,
cache,
printer,
)
@ -1326,13 +1482,14 @@ pub(crate) async fn update_environment(
})
}
/// Determine the [`RequiresPython`] requirement for a PEP 723 script.
pub(crate) async fn script_python_requirement(
/// Determine the [`RequiresPython`] requirement for a new PEP 723 script.
pub(crate) async fn init_script_python_requirement(
python: Option<&str>,
directory: &Path,
no_pin_python: bool,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
no_config: bool,
client_builder: &BaseClientBuilder<'_>,
cache: &Cache,
reporter: &PythonDownloadReporter,
@ -1342,9 +1499,12 @@ pub(crate) async fn script_python_requirement(
PythonRequest::parse(request)
} else if let (false, Some(request)) = (
no_pin_python,
PythonVersionFile::discover(directory, false, false)
.await?
.and_then(PythonVersionFile::into_version),
PythonVersionFile::discover(
directory,
&VersionFileDiscoveryOptions::default().with_no_config(no_config),
)
.await?
.and_then(PythonVersionFile::into_version),
) {
// (2) Request from `.python-version`
request

View file

@ -47,6 +47,7 @@ pub(crate) async fn remove(
concurrency: Concurrency,
native_tls: bool,
allow_insecure_host: &[TrustedHost],
no_config: bool,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
@ -193,6 +194,7 @@ pub(crate) async fn remove(
connectivity,
native_tls,
allow_insecure_host,
no_config,
cache,
printer,
)

View file

@ -28,7 +28,7 @@ use uv_normalize::PackageName;
use uv_python::{
EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionRequest,
PythonPreference, PythonRequest, PythonVersionFile, VersionFileDiscoveryOptions,
};
use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_resolver::{InstallTarget, Lock};
@ -45,8 +45,8 @@ use crate::commands::pip::operations::Modifications;
use crate::commands::project::environment::CachedEnvironment;
use crate::commands::project::lock::LockMode;
use crate::commands::project::{
default_dependency_groups, validate_requires_python, DependencyGroupsTarget,
EnvironmentSpecification, ProjectError, PythonRequestSource, WorkspacePython,
default_dependency_groups, validate_requires_python, validate_script_requires_python,
DependencyGroupsTarget, EnvironmentSpecification, ProjectError, ScriptPython, WorkspacePython,
};
use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::{diagnostics, project, ExitStatus, SharedState};
@ -178,31 +178,17 @@ pub(crate) async fn run(
}
}
let (source, python_request) = if let Some(request) = python.as_deref() {
// (1) Explicit request from user
let source = PythonRequestSource::UserRequest;
let request = Some(PythonRequest::parse(request));
(source, request)
} else if let Some(file) = PythonVersionFile::discover(&project_dir, false, false).await? {
// (2) Request from `.python-version`
let source = PythonRequestSource::DotPythonVersion(file.file_name().to_string());
let request = file.into_version();
(source, request)
} else {
// (3) `Requires-Python` in the script
let request = script
.metadata()
.requires_python
.as_ref()
.map(|requires_python| {
PythonRequest::Version(VersionRequest::Range(
requires_python.clone(),
PythonVariant::Default,
))
});
let source = PythonRequestSource::RequiresPython;
(source, request)
};
let ScriptPython {
source,
python_request,
requires_python,
} = ScriptPython::from_request(
python.as_deref().map(PythonRequest::parse),
None,
&script,
no_config,
)
.await?;
let client_builder = BaseClientBuilder::new()
.connectivity(connectivity)
@ -221,30 +207,18 @@ pub(crate) async fn run(
.await?
.into_interpreter();
if let Some(requires_python) = script.metadata().requires_python.as_ref() {
if !requires_python.contains(interpreter.python_version()) {
let err = match source {
PythonRequestSource::UserRequest => {
ProjectError::RequestedPythonScriptIncompatibility(
interpreter.python_version().clone(),
requires_python.clone(),
)
}
PythonRequestSource::DotPythonVersion(file) => {
ProjectError::DotPythonVersionScriptIncompatibility(
file,
interpreter.python_version().clone(),
requires_python.clone(),
)
}
PythonRequestSource::RequiresPython => {
ProjectError::RequiresPythonScriptIncompatibility(
interpreter.python_version().clone(),
requires_python.clone(),
)
}
};
warn_user!("{err}");
if let Some((requires_python, requires_python_source)) = requires_python {
match validate_script_requires_python(
&interpreter,
None,
&requires_python,
&requires_python_source,
&source,
) {
Ok(()) => {}
Err(err) => {
warn_user!("{err}");
}
}
}
@ -536,7 +510,9 @@ pub(crate) async fn run(
requires_python,
} = WorkspacePython::from_request(
python.as_deref().map(PythonRequest::parse),
project.workspace(),
Some(project.workspace()),
project_dir,
no_config,
)
.await?;
@ -555,7 +531,7 @@ pub(crate) async fn run(
if let Some(requires_python) = requires_python.as_ref() {
validate_requires_python(
&interpreter,
project.workspace(),
Some(project.workspace()),
requires_python,
&source,
)?;
@ -583,6 +559,7 @@ pub(crate) async fn run(
connectivity,
native_tls,
allow_insecure_host,
no_config,
cache,
printer,
)
@ -737,9 +714,12 @@ pub(crate) async fn run(
Some(PythonRequest::parse(request))
// (2) Request from `.python-version`
} else {
PythonVersionFile::discover(&project_dir, no_config, false)
.await?
.and_then(PythonVersionFile::into_version)
PythonVersionFile::discover(
&project_dir,
&VersionFileDiscoveryOptions::default().with_no_config(no_config),
)
.await?
.and_then(PythonVersionFile::into_version)
};
let python = PythonInstallation::find_or_download(

View file

@ -59,6 +59,7 @@ pub(crate) async fn sync(
concurrency: Concurrency,
native_tls: bool,
allow_insecure_host: &[TrustedHost],
no_config: bool,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
@ -118,6 +119,7 @@ pub(crate) async fn sync(
connectivity,
native_tls,
allow_insecure_host,
no_config,
cache,
printer,
)

View file

@ -52,6 +52,7 @@ pub(crate) async fn tree(
concurrency: Concurrency,
native_tls: bool,
allow_insecure_host: &[TrustedHost],
no_config: bool,
cache: &Cache,
printer: Printer,
) -> Result<ExitStatus> {
@ -74,12 +75,14 @@ pub(crate) async fn tree(
Some(
ProjectInterpreter::discover(
&workspace,
project_dir,
python.as_deref().map(PythonRequest::parse),
python_preference,
python_downloads,
connectivity,
native_tls,
allow_insecure_host,
no_config,
cache,
printer,
)

View file

@ -4,15 +4,14 @@ use std::path::Path;
use uv_cache::Cache;
use uv_fs::Simplified;
use uv_python::{
EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest, PythonVariant,
PythonVersionFile, VersionRequest,
};
use uv_resolver::RequiresPython;
use uv_warnings::warn_user_once;
use uv_python::{EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest};
use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError};
use crate::commands::{project::find_requires_python, ExitStatus};
use crate::commands::{
project::{validate_requires_python, WorkspacePython},
ExitStatus,
};
/// Find a Python interpreter.
pub(crate) async fn find(
@ -30,50 +29,55 @@ pub(crate) async fn find(
EnvironmentPreference::Any
};
// (1) Explicit request from user
let mut request = request.map(|request| PythonRequest::parse(&request));
// (2) Request from `.python-version`
if request.is_none() {
request = PythonVersionFile::discover(project_dir, no_config, false)
.await?
.and_then(PythonVersionFile::into_version);
}
// (3) `Requires-Python` in `pyproject.toml`
if request.is_none() && !no_project {
let project =
match VirtualProject::discover(project_dir, &DiscoveryOptions::default()).await {
Ok(project) => Some(project),
Err(WorkspaceError::MissingProject(_)) => None,
Err(WorkspaceError::MissingPyprojectToml) => None,
Err(WorkspaceError::NonWorkspace(_)) => None,
Err(err) => {
warn_user_once!("{err}");
None
}
};
if let Some(project) = project {
request = find_requires_python(project.workspace())
.as_ref()
.map(RequiresPython::specifiers)
.map(|specifiers| {
PythonRequest::Version(VersionRequest::Range(
specifiers.clone(),
PythonVariant::Default,
))
});
let project = if no_project {
None
} else {
match VirtualProject::discover(project_dir, &DiscoveryOptions::default()).await {
Ok(project) => Some(project),
Err(WorkspaceError::MissingProject(_)) => None,
Err(WorkspaceError::MissingPyprojectToml) => None,
Err(WorkspaceError::NonWorkspace(_)) => None,
Err(err) => {
warn_user_once!("{err}");
None
}
}
}
};
let WorkspacePython {
source,
python_request,
requires_python,
} = WorkspacePython::from_request(
request.map(|request| PythonRequest::parse(&request)),
project.as_ref().map(VirtualProject::workspace),
project_dir,
no_config,
)
.await?;
let python = PythonInstallation::find(
&request.unwrap_or_default(),
&python_request.unwrap_or_default(),
environment_preference,
python_preference,
cache,
)?;
// Warn if the discovered Python version is incompatible with the current workspace
if let Some(requires_python) = requires_python {
match validate_requires_python(
python.interpreter(),
project.as_ref().map(VirtualProject::workspace),
&requires_python,
&source,
) {
Ok(()) => {}
Err(err) => {
warn_user!("{err}");
}
}
};
println!(
"{}",
std::path::absolute(python.interpreter().sys_executable())?.simplified_display()

View file

@ -18,7 +18,10 @@ use uv_python::downloads::{DownloadResult, ManagedPythonDownload, PythonDownload
use uv_python::managed::{
python_executable_dir, ManagedPythonInstallation, ManagedPythonInstallations,
};
use uv_python::{PythonDownloads, PythonInstallationKey, PythonRequest, PythonVersionFile};
use uv_python::{
PythonDownloads, PythonInstallationKey, PythonRequest, PythonVersionFile,
VersionFileDiscoveryOptions, VersionFilePreference,
};
use uv_shell::Shell;
use uv_trampoline_builder::{Launcher, LauncherKind};
use uv_warnings::warn_user;
@ -123,17 +126,22 @@ pub(crate) async fn install(
// Resolve the requests
let mut is_default_install = false;
let requests: Vec<_> = if targets.is_empty() {
PythonVersionFile::discover(project_dir, no_config, true)
.await?
.map(PythonVersionFile::into_versions)
.unwrap_or_else(|| {
// If no version file is found and no requests were made
is_default_install = true;
vec![PythonRequest::Default]
})
.into_iter()
.map(InstallRequest::new)
.collect::<Result<Vec<_>>>()?
PythonVersionFile::discover(
project_dir,
&VersionFileDiscoveryOptions::default()
.with_no_config(no_config)
.with_preference(VersionFilePreference::Versions),
)
.await?
.map(PythonVersionFile::into_versions)
.unwrap_or_else(|| {
// If no version file is found and no requests were made
is_default_install = true;
vec![PythonRequest::Default]
})
.into_iter()
.map(InstallRequest::new)
.collect::<Result<Vec<_>>>()?
} else {
targets
.iter()

View file

@ -10,7 +10,7 @@ use uv_cache::Cache;
use uv_fs::Simplified;
use uv_python::{
EnvironmentPreference, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile,
PYTHON_VERSION_FILENAME,
VersionFileDiscoveryOptions, PYTHON_VERSION_FILENAME,
};
use uv_warnings::warn_user_once;
use uv_workspace::{DiscoveryOptions, VirtualProject};
@ -40,7 +40,8 @@ pub(crate) async fn pin(
}
};
let version_file = PythonVersionFile::discover(project_dir, false, false).await;
let version_file =
PythonVersionFile::discover(project_dir, &VersionFileDiscoveryOptions::default()).await;
let Some(request) = request else {
// Display the current pinned Python version

View file

@ -22,17 +22,16 @@ use uv_install_wheel::linker::LinkMode;
use uv_pypi_types::Requirement;
use uv_python::{
EnvironmentPreference, PythonDownloads, PythonInstallation, PythonPreference, PythonRequest,
PythonVariant, PythonVersionFile, VersionRequest,
};
use uv_resolver::{ExcludeNewer, FlatIndex, RequiresPython};
use uv_resolver::{ExcludeNewer, FlatIndex};
use uv_shell::Shell;
use uv_types::{BuildContext, BuildIsolation, HashStrategy};
use uv_warnings::warn_user_once;
use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::{DiscoveryOptions, VirtualProject, WorkspaceError};
use crate::commands::pip::loggers::{DefaultInstallLogger, InstallLogger};
use crate::commands::pip::operations::Changelog;
use crate::commands::project::find_requires_python;
use crate::commands::project::{validate_requires_python, WorkspacePython};
use crate::commands::reporters::PythonDownloadReporter;
use crate::commands::{ExitStatus, SharedState};
use crate::printer::Printer;
@ -184,35 +183,22 @@ async fn venv_impl(
let reporter = PythonDownloadReporter::single(printer);
// (1) Explicit request from user
let mut interpreter_request = python_request.map(PythonRequest::parse);
// (2) Request from `.python-version`
if interpreter_request.is_none() {
interpreter_request = PythonVersionFile::discover(project_dir, no_config, false)
.await
.into_diagnostic()?
.and_then(PythonVersionFile::into_version);
}
// (3) `Requires-Python` in `pyproject.toml`
if interpreter_request.is_none() {
if let Some(project) = project {
interpreter_request = find_requires_python(project.workspace())
.as_ref()
.map(RequiresPython::specifiers)
.map(|specifiers| {
PythonRequest::Version(VersionRequest::Range(
specifiers.clone(),
PythonVariant::Default,
))
});
}
}
let WorkspacePython {
source,
python_request,
requires_python,
} = WorkspacePython::from_request(
python_request.map(PythonRequest::parse),
project.as_ref().map(VirtualProject::workspace),
project_dir,
no_config,
)
.await
.into_diagnostic()?;
// Locate the Python interpreter to use in the environment
let python = PythonInstallation::find_or_download(
interpreter_request.as_ref(),
python_request.as_ref(),
EnvironmentPreference::OnlySystem,
python_preference,
python_downloads,
@ -253,6 +239,21 @@ async fn venv_impl(
.into_diagnostic()?;
}
// Check if the discovered Python version is incompatible with the current workspace
if let Some(requires_python) = requires_python {
match validate_requires_python(
&interpreter,
project.as_ref().map(VirtualProject::workspace),
&requires_python,
&source,
) {
Ok(()) => {}
Err(err) => {
warn_user!("{err}");
}
}
};
writeln!(
printer.stderr(),
"Creating virtual environment {}at: {}",

View file

@ -1278,6 +1278,7 @@ async fn run_project(
globals.connectivity,
globals.native_tls,
&globals.allow_insecure_host,
no_config,
&cache,
printer,
)
@ -1374,6 +1375,7 @@ async fn run_project(
globals.concurrency,
globals.native_tls,
&globals.allow_insecure_host,
no_config,
&cache,
printer,
)
@ -1403,6 +1405,7 @@ async fn run_project(
globals.concurrency,
globals.native_tls,
&globals.allow_insecure_host,
no_config,
&cache,
printer,
)
@ -1455,6 +1458,7 @@ async fn run_project(
globals.concurrency,
globals.native_tls,
&globals.allow_insecure_host,
no_config,
&cache,
printer,
))
@ -1496,6 +1500,7 @@ async fn run_project(
globals.concurrency,
globals.native_tls,
&globals.allow_insecure_host,
no_config,
&cache,
printer,
)
@ -1531,6 +1536,7 @@ async fn run_project(
globals.concurrency,
globals.native_tls,
&globals.allow_insecure_host,
no_config,
&cache,
printer,
)
@ -1566,6 +1572,7 @@ async fn run_project(
globals.concurrency,
globals.native_tls,
&globals.allow_insecure_host,
no_config,
globals.quiet,
&cache,
printer,

View file

@ -2005,6 +2005,43 @@ fn init_requires_python_specifiers() -> Result<()> {
Ok(())
}
/// Run `uv init`, inferring the `requires-python` from the `.python-version` file.
#[test]
fn init_requires_python_version_file() -> Result<()> {
let context = TestContext::new_with_versions(&["3.8", "3.12"]);
context.temp_dir.child(".python-version").write_str("3.8")?;
let child = context.temp_dir.join("foo");
uv_snapshot!(context.filters(), context.init().current_dir(&context.temp_dir).arg(&child), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Initialized project `foo` at `[TEMP_DIR]/foo`
"###);
let pyproject_toml = fs_err::read_to_string(child.join("pyproject.toml"))?;
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
pyproject_toml, @r###"
[project]
name = "foo"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.8"
dependencies = []
"###
);
});
Ok(())
}
/// Run `uv init` from within an unmanaged project.
#[test]
fn init_unmanaged() -> Result<()> {

View file

@ -195,11 +195,43 @@ fn python_find_pin() {
----- stderr -----
"###);
let child_dir = context.temp_dir.child("child");
child_dir.create_dir_all().unwrap();
// We should also find pinned versions in the parent directory
uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir), @r###"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.12]
----- stderr -----
"###);
uv_snapshot!(context.filters(), context.python_pin().arg("3.11").current_dir(&child_dir), @r###"
success: true
exit_code: 0
----- stdout -----
Updated `.python-version` from `3.12` -> `3.11`
----- stderr -----
"###);
// Unless the child directory also has a pin
uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir), @r###"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.11]
----- stderr -----
"###);
}
#[test]
fn python_find_project() {
let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]);
let context: TestContext = TestContext::new_with_versions(&["3.10", "3.11", "3.12"]);
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml
@ -207,7 +239,7 @@ fn python_find_project() {
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
requires-python = ">=3.11"
dependencies = ["anyio==3.7.0"]
"#})
.unwrap();
@ -217,26 +249,90 @@ fn python_find_project() {
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.12]
----- stderr -----
"###);
// Unless explicitly requested
uv_snapshot!(context.filters(), context.python_find().arg("3.11"), @r###"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.11]
----- stderr -----
"###);
// Unless explicitly requested
uv_snapshot!(context.filters(), context.python_find().arg("3.10"), @r###"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.10]
----- stderr -----
warning: The requested interpreter resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11`
"###);
// Or `--no-project` is used
uv_snapshot!(context.filters(), context.python_find().arg("--no-project"), @r###"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.10]
----- stderr -----
"###);
// But a pin should take precedence
uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###"
success: true
exit_code: 0
----- stdout -----
Pinned `.python-version` to `3.12`
----- stderr -----
"###);
uv_snapshot!(context.filters(), context.python_find(), @r###"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.12]
----- stderr -----
"###);
// Create a pin that's incompatible with the project
uv_snapshot!(context.filters(), context.python_pin().arg("3.10").arg("--no-workspace"), @r###"
success: true
exit_code: 0
----- stdout -----
Updated `.python-version` from `3.12` -> `3.10`
----- stderr -----
"###);
// We should warn on subsequent uses, but respect the pinned version?
uv_snapshot!(context.filters(), context.python_find(), @r###"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.10]
----- stderr -----
warning: The Python request from `.python-version` resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11`
"###);
// Unless the pin file is outside the project, in which case we should just ignore it
let child_dir = context.temp_dir.child("child");
child_dir.create_dir_all().unwrap();
let pyproject_toml = child_dir.child("pyproject.toml");
pyproject_toml
.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["anyio==3.7.0"]
"#})
.unwrap();
uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir), @r###"
success: true
exit_code: 0
----- stdout -----
[PYTHON-3.11]
----- stderr -----

View file

@ -2434,6 +2434,7 @@ fn sync_custom_environment_path() -> Result<()> {
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12`
Creating virtual environment at: foo
Activate with: source foo/[BIN]/activate
"###);
@ -3603,6 +3604,7 @@ fn sync_invalid_environment() -> Result<()> {
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12`
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"###);
@ -3669,6 +3671,7 @@ fn sync_invalid_environment() -> Result<()> {
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12`
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"###);
@ -3755,6 +3758,127 @@ fn sync_no_sources_missing_member() -> Result<()> {
Ok(())
}
#[test]
fn sync_python_version() -> Result<()> {
let context: TestContext = TestContext::new_with_versions(&["3.10", "3.11", "3.12"]);
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc::indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["anyio==3.7.0"]
"#})?;
// We should respect the project's required version, not the first on the path
uv_snapshot!(context.filters(), context.sync(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
Creating virtual environment at: .venv
Resolved 4 packages in [TIME]
Prepared 3 packages in [TIME]
Installed 3 packages in [TIME]
+ anyio==3.7.0
+ idna==3.6
+ sniffio==1.3.1
"###);
// Unless explicitly requested...
uv_snapshot!(context.filters(), context.sync().arg("--python").arg("3.10"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Using CPython 3.10.[X] interpreter at: [PYTHON-3.10]
error: The requested interpreter resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11`
"###);
// But a pin should take precedence
uv_snapshot!(context.filters(), context.python_pin().arg("3.12"), @r###"
success: true
exit_code: 0
----- stdout -----
Pinned `.python-version` to `3.12`
----- stderr -----
"###);
uv_snapshot!(context.filters(), context.sync(), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Removed virtual environment at: .venv
Creating virtual environment at: .venv
Resolved 4 packages in [TIME]
Installed 3 packages in [TIME]
+ anyio==3.7.0
+ idna==3.6
+ sniffio==1.3.1
"###);
// Create a pin that's incompatible with the project
uv_snapshot!(context.filters(), context.python_pin().arg("3.10").arg("--no-workspace"), @r###"
success: true
exit_code: 0
----- stdout -----
Updated `.python-version` from `3.12` -> `3.10`
----- stderr -----
"###);
// We should warn on subsequent uses, but respect the pinned version?
uv_snapshot!(context.filters(), context.sync(), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
Using CPython 3.10.[X] interpreter at: [PYTHON-3.10]
error: The Python request from `.python-version` resolved to Python 3.10.[X], which is incompatible with the project's Python requirement: `>=3.11`
"###);
// Unless the pin file is outside the project, in which case we should just ignore it entirely
let child_dir = context.temp_dir.child("child");
child_dir.create_dir_all().unwrap();
let pyproject_toml = child_dir.child("pyproject.toml");
pyproject_toml
.write_str(indoc::indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["anyio==3.7.0"]
"#})
.unwrap();
uv_snapshot!(context.filters(), context.sync().current_dir(&child_dir), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
Creating virtual environment at: .venv
Resolved 4 packages in [TIME]
Installed 3 packages in [TIME]
+ anyio==3.7.0
+ idna==3.6
+ sniffio==1.3.1
"###);
Ok(())
}
#[test]
fn sync_explicit() -> Result<()> {
let context = TestContext::new("3.12");

View file

@ -475,6 +475,20 @@ fn create_venv_respects_pyproject_requires_python() -> Result<()> {
context.venv.assert(predicates::path::is_dir());
// We warn if we receive an incompatible version
uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11"), @r###"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Using CPython 3.11.[X] interpreter at: [PYTHON-3.11]
warning: The requested interpreter resolved to Python 3.11.[X], which is incompatible with the project's Python requirement: `>=3.12`
Creating virtual environment at: .venv
Activate with: source .venv/[BIN]/activate
"###
);
Ok(())
}