Skip to content
Draft
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
7 changes: 7 additions & 0 deletions crates/uv-preview/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ pub enum PreviewFeature {
PublishRequireNormalized = 1 << 25,
Audit = 1 << 26,
ProjectDirectoryMustExist = 1 << 27,
ToolRequiresPython = 1 << 28,
}

impl PreviewFeature {
Expand Down Expand Up @@ -228,6 +229,7 @@ impl PreviewFeature {
Self::PublishRequireNormalized => "publish-require-normalized",
Self::Audit => "audit",
Self::ProjectDirectoryMustExist => "project-directory-must-exist",
Self::ToolRequiresPython => "tool-requires-python",
}
}
}
Expand Down Expand Up @@ -275,6 +277,7 @@ impl FromStr for PreviewFeature {
"publish-require-normalized" => Self::PublishRequireNormalized,
"audit" => Self::Audit,
"project-directory-must-exist" => Self::ProjectDirectoryMustExist,
"tool-requires-python" => Self::ToolRequiresPython,
_ => return Err(PreviewFeatureParseError),
})
}
Expand Down Expand Up @@ -524,6 +527,10 @@ mod tests {
PreviewFeature::ProjectDirectoryMustExist.as_str(),
"project-directory-must-exist"
);
assert_eq!(
PreviewFeature::ToolRequiresPython.as_str(),
"tool-requires-python"
);
}

#[test]
Expand Down
50 changes: 50 additions & 0 deletions crates/uv-python/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1953,6 +1953,18 @@ impl PythonRequest {
Ok(rest.parse().ok())
}

/// Create a request from a `Requires-Python` constraint.
pub fn from_requires_python(requires_python: &RequiresPython) -> Option<Self> {
if requires_python.specifiers().is_empty() {
return None;
}

Some(Self::Version(VersionRequest::Range(
requires_python.specifiers().clone(),
PythonVariant::Default,
)))
}

/// Check if this request includes a specific patch version.
pub fn includes_patch(&self) -> bool {
match self {
Expand Down Expand Up @@ -4375,6 +4387,44 @@ mod tests {
);
}

#[test]
fn python_request_from_requires_python() {
let requires_python =
RequiresPython::from_specifiers(&VersionSpecifiers::from_str(">=3.11,<3.13").unwrap());

let request = PythonRequest::from_requires_python(&requires_python).unwrap();
assert_eq!(
request,
PythonRequest::Version(VersionRequest::Range(
VersionSpecifiers::from_str(">=3.11,<3.13").unwrap(),
PythonVariant::Default,
))
);
}

#[test]
fn python_request_from_upper_bound_only_requires_python() {
let requires_python =
RequiresPython::from_specifiers(&VersionSpecifiers::from_str("<3.12").unwrap());

let request = PythonRequest::from_requires_python(&requires_python).unwrap();
assert_eq!(
request,
PythonRequest::Version(VersionRequest::Range(
VersionSpecifiers::from_str("<3.12").unwrap(),
PythonVariant::Default,
))
);
}

#[test]
fn python_request_from_unbounded_requires_python() {
let requires_python = RequiresPython::from_specifiers(&VersionSpecifiers::empty());

let request = PythonRequest::from_requires_python(&requires_python);
assert!(request.is_none());
}

#[test]
fn intersects_requires_python_exact() {
let requires_python =
Expand Down
13 changes: 3 additions & 10 deletions crates/uv/src/commands/build_frontend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use uv_distribution_filename::{
};
use uv_distribution_types::{
ConfigSettings, DependencyMetadata, ExtraBuildVariables, Index, IndexLocations,
PackageConfigSettings, Requirement, RequiresPython, SourceDist,
PackageConfigSettings, Requirement, SourceDist,
};
use uv_fs::{Simplified, relative_to};
use uv_install_wheel::LinkMode;
Expand All @@ -33,8 +33,7 @@ use uv_pep440::Version;
use uv_preview::Preview;
use uv_python::{
EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions,
VersionRequest,
PythonPreference, PythonRequest, PythonVersionFile, VersionFileDiscoveryOptions,
};
use uv_requirements::RequirementsSource;
use uv_resolver::{ExcludeNewer, FlatIndex};
Expand Down Expand Up @@ -528,13 +527,7 @@ async fn build_package(
let groups = DependencyGroupsWithDefaults::none();
interpreter_request = find_requires_python(workspace, &groups)?
.as_ref()
.map(RequiresPython::specifiers)
.map(|specifiers| {
PythonRequest::Version(VersionRequest::Range(
specifiers.clone(),
PythonVariant::Default,
))
});
.and_then(PythonRequest::from_requires_python);
}
}

Expand Down
6 changes: 2 additions & 4 deletions crates/uv/src/commands/project/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -652,10 +652,8 @@ async fn determine_requires_python(
.flatten()
{
// (3) `requires-python` from the workspace
let python_request = PythonRequest::Version(VersionRequest::Range(
requires_python.specifiers().clone(),
PythonVariant::Default,
));
let python_request = PythonRequest::from_requires_python(&requires_python)
.expect("workspace requires-python should be bounded");

// Pin to the minor version.
let python_pin = if pin_python {
Expand Down
26 changes: 6 additions & 20 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use uv_pypi_types::{ConflictItem, ConflictKind, ConflictSet, Conflicts};
use uv_python::{
BrokenLink, EnvironmentPreference, Interpreter, InvalidEnvironmentKind, PythonDownloads,
PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonSource,
PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions, VersionRequest,
PythonVersionFile, VersionFileDiscoveryOptions,
};
use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements};
use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification};
Expand Down Expand Up @@ -1225,13 +1225,7 @@ impl WorkspacePython {
// (3) `requires-python` in `pyproject.toml`
let request = requires_python
.as_ref()
.map(RequiresPython::specifiers)
.map(|specifiers| {
PythonRequest::Version(VersionRequest::Range(
specifiers.clone(),
PythonVariant::Default,
))
});
.and_then(PythonRequest::from_requires_python);
let source = PythonRequestSource::RequiresPython;
(source, request)
};
Expand Down Expand Up @@ -1325,22 +1319,14 @@ impl ScriptPython {
)
} else if let Some(specifiers) = script.metadata().requires_python.as_ref() {
// (3) `requires-python` from script metadata
let request = PythonRequest::Version(VersionRequest::Range(
specifiers.clone(),
PythonVariant::Default,
));
(PythonRequestSource::RequiresPython, Some(request))
let requires_python = RequiresPython::from_specifiers(specifiers);
let request = PythonRequest::from_requires_python(&requires_python);
(PythonRequestSource::RequiresPython, request)
} else {
// (4) `requires-python` from workspace `pyproject.toml`
let request = workspace_requires_python
.as_ref()
.map(RequiresPython::specifiers)
.map(|specifiers| {
PythonRequest::Version(VersionRequest::Range(
specifiers.clone(),
PythonVariant::Default,
))
});
.and_then(PythonRequest::from_requires_python);
(PythonRequestSource::RequiresPython, request)
};

Expand Down
154 changes: 150 additions & 4 deletions crates/uv/src/commands/tool/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,24 @@ use std::{
ffi::OsString,
fmt::Write,
path::Path,
str::FromStr,
};
use tracing::{debug, warn};
use uv_cache::Cache;
use uv_client::BaseClientBuilder;
use uv_distribution_types::Requirement;
use uv_distribution_types::{InstalledDist, Name};
use uv_cache::{Cache, CacheBucket};
use uv_client::{BaseClientBuilder, Connectivity};
use uv_configuration::GitLfsSetting;
use uv_distribution_types::{
InstalledDist, Name, Requirement, RequirementSource, RequiresPython, UnresolvedRequirement,
};
use uv_fs::Simplified;
#[cfg(unix)]
use uv_fs::replace_symlink;
use uv_git::GitResolver;
use uv_installer::SitePackages;
use uv_normalize::PackageName;
use uv_pep440::{Version, VersionSpecifier, VersionSpecifiers};
use uv_preview::Preview;
use uv_pypi_types::{LenientVersionSpecifiers, PyProjectToml};
use uv_python::{
EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation,
PythonPreference, PythonRequest, PythonVariant, VersionRequest,
Expand Down Expand Up @@ -72,6 +77,86 @@ pub(crate) fn remove_entrypoints(tool: &Tool) {
}
}

/// Infer a Python request from a direct source requirement (`path` or `git`) by reading
/// `requires-python` from `pyproject.toml`.
pub(crate) async fn infer_python_request_from_requirement(
requirement: &UnresolvedRequirement,
lfs: GitLfsSetting,
git_resolver: &GitResolver,
client_builder: &BaseClientBuilder<'_>,
cache: &Cache,
) -> Option<PythonRequest> {
let requirement = requirement
.clone()
.augment_requirement(None, None, None, lfs.into(), None);
let source = requirement.source();

let requires_python = match source.as_ref() {
RequirementSource::Directory { install_path, .. } => {
read_requires_python_from_pyproject(&install_path.join("pyproject.toml"))?
}
RequirementSource::Git {
git, subdirectory, ..
} => {
let client = client_builder.build();
let fetch = match git_resolver
.fetch(
git,
client.disable_ssl(git.repository()),
client.connectivity() == Connectivity::Offline,
cache.bucket(CacheBucket::Git),
None,
)
.await
{
Ok(fetch) => fetch,
Err(err) => {
debug!(
"Failed to fetch git source while inferring Python request (`{requirement}`): {err}"
);
return None;
}
};

let source_path = if let Some(subdirectory) = subdirectory {
fetch.path().join(subdirectory)
} else {
fetch.path().to_path_buf()
};

read_requires_python_from_pyproject(&source_path.join("pyproject.toml"))?
}
_ => return None,
};

python_request_from_requires_python(&requires_python)
}

fn read_requires_python_from_pyproject(pyproject_path: &Path) -> Option<VersionSpecifiers> {
let content = fs_err::read_to_string(pyproject_path).ok()?;
let pyproject = PyProjectToml::from_toml(&content, pyproject_path.user_display()).ok()?;
let project = pyproject.project?;

if project
.dynamic
.as_ref()
.is_some_and(|dynamic| dynamic.iter().any(|field| field == "requires-python"))
{
return None;
}

let requires_python = project.requires_python?;
let requires_python = LenientVersionSpecifiers::from_str(&requires_python).ok()?;
Some(VersionSpecifiers::from(requires_python))
}

fn python_request_from_requires_python(
requires_python: &VersionSpecifiers,
) -> Option<PythonRequest> {
let requires_python = RequiresPython::from_specifiers(requires_python);
PythonRequest::from_requires_python(&requires_python)
}

/// Given a no-solution error and the [`Interpreter`] that was used during the solve, attempt to
/// discover an alternate [`Interpreter`] that satisfies the `requires-python` constraint.
pub(crate) async fn refine_interpreter(
Expand Down Expand Up @@ -424,3 +509,64 @@ fn hint_executable_from_dependency(

Ok(())
}

#[cfg(test)]
mod tests {
use std::str::FromStr;

use uv_pep440::{Version, VersionSpecifiers};

use super::{python_request_from_requires_python, read_requires_python_from_pyproject};

#[test]
fn infer_python_request_uses_full_specifier_range() {
let requires_python = VersionSpecifiers::from_str(">=3.11,<3.14").unwrap();

let request = python_request_from_requires_python(&requires_python).unwrap();
let uv_python::PythonRequest::Version(uv_python::VersionRequest::Range(specifiers, _)) =
request
else {
panic!("Expected version range request");
};

assert!(specifiers.contains(&Version::new([3, 11])));
assert!(specifiers.contains(&Version::new([3, 12])));
assert!(!specifiers.contains(&Version::new([3, 14])));
}

#[test]
fn infer_python_request_preserves_upper_bound_only_specifier() {
let requires_python = VersionSpecifiers::from_str("<3.12").unwrap();

let request = python_request_from_requires_python(&requires_python).unwrap();
let uv_python::PythonRequest::Version(uv_python::VersionRequest::Range(specifiers, _)) =
request
else {
panic!("Expected version range request");
};

assert!(specifiers.contains(&Version::new([3, 11])));
assert!(!specifiers.contains(&Version::new([3, 12])));
}

#[test]
fn read_requires_python_from_pyproject_file() {
let tempdir = tempfile::tempdir().unwrap();
let pyproject_path = tempdir.path().join("pyproject.toml");
fs_err::write(
&pyproject_path,
r#"
[project]
name = "example"
version = "0.1.0"
requires-python = ">=3.11,<3.14"
"#,
)
.unwrap();

let requires_python = read_requires_python_from_pyproject(&pyproject_path).unwrap();

assert!(requires_python.contains(&Version::new([3, 11])));
assert!(!requires_python.contains(&Version::new([3, 14])));
}
}
Loading
Loading