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 Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/uv-python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ uv-cache-key = { workspace = true }
uv-client = { workspace = true }
uv-dirs = { workspace = true }
uv-distribution-filename = { workspace = true }
uv-distribution-types = { workspace = true }
uv-extract = { workspace = true }
uv-fs = { workspace = true }
uv-install-wheel = { workspace = true }
Expand Down
139 changes: 138 additions & 1 deletion crates/uv-python/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use thiserror::Error;
use tracing::{debug, instrument, trace};
use uv_cache::Cache;
use uv_client::BaseClient;
use uv_distribution_types::RequiresPython;
use uv_fs::Simplified;
use uv_fs::which::is_executable;
use uv_pep440::{
Expand Down Expand Up @@ -2241,9 +2242,54 @@ impl PythonRequest {
Self::Key(download_request) => download_request
.version()
.and_then(VersionRequest::as_pep440_version),
_ => None,
Self::Default
| Self::Any
| Self::Directory(_)
| Self::File(_)
| Self::ExecutableName(_)
| Self::Implementation(_) => None,
}
}

/// Convert an interpreter request into [`VersionSpecifiers`] representing the range of
/// compatible versions.
///
/// Returns `None` if the request doesn't carry version constraints (e.g., a path or
/// executable name).
pub fn as_version_specifiers(&self) -> Option<VersionSpecifiers> {
match self {
Self::Version(version) | Self::ImplementationVersion(_, version) => {
version.as_version_specifiers()
}
Self::Key(download_request) => download_request
.version()
.and_then(VersionRequest::as_version_specifiers),
Self::Default
| Self::Any
| Self::Directory(_)
| Self::File(_)
| Self::ExecutableName(_)
| Self::Implementation(_) => None,
}
}

/// Returns `true` when this request is compatible with the given `requires-python` specifier.
///
/// Requests without version constraints (e.g., paths, executable names) are always considered
/// compatible. For versioned requests, compatibility means the request's version range has a
/// non-empty intersection with the `requires-python` range.
pub fn intersects_requires_python(&self, requires_python: &RequiresPython) -> bool {
let Some(specifiers) = self.as_version_specifiers() else {
return true;
};

let request_range = release_specifiers_to_ranges(specifiers);
let requires_python_range =
release_specifiers_to_ranges(requires_python.specifiers().clone());
!request_range
.intersection(&requires_python_range)
.is_empty()
}
}

impl PythonSource {
Expand Down Expand Up @@ -3101,6 +3147,38 @@ impl VersionRequest {
),
}
}

/// Convert this request into [`VersionSpecifiers`] representing the range of compatible
/// versions.
///
/// Returns `None` for requests without version constraints (e.g., [`VersionRequest::Default`]
/// and [`VersionRequest::Any`]).
pub fn as_version_specifiers(&self) -> Option<VersionSpecifiers> {
match self {
Self::Default | Self::Any => None,
Self::Major(major, _) => Some(VersionSpecifiers::from(
VersionSpecifier::equals_star_version(Version::new([u64::from(*major)])),
)),
Self::MajorMinor(major, minor, _) => Some(VersionSpecifiers::from(
VersionSpecifier::equals_star_version(Version::new([
u64::from(*major),
u64::from(*minor),
])),
)),
Self::MajorMinorPatch(major, minor, patch, _) => {
Some(VersionSpecifiers::from(VersionSpecifier::equals_version(
Version::new([u64::from(*major), u64::from(*minor), u64::from(*patch)]),
)))
}
Self::MajorMinorPrerelease(major, minor, prerelease, _) => {
Some(VersionSpecifiers::from(VersionSpecifier::equals_version(
Version::new([u64::from(*major), u64::from(*minor), 0])
.with_pre(Some(*prerelease)),
)))
}
Self::Range(specifiers, _) => Some(specifiers.clone()),
}
}
}

impl FromStr for VersionRequest {
Expand Down Expand Up @@ -3498,6 +3576,7 @@ mod tests {
use assert_fs::{TempDir, prelude::*};
use target_lexicon::{Aarch64Architecture, Architecture};
use test_log::test;
use uv_distribution_types::RequiresPython;
use uv_pep440::{Prerelease, PrereleaseKind, Version, VersionSpecifiers};

use crate::{
Expand Down Expand Up @@ -4294,4 +4373,62 @@ mod tests {
None
);
}

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

assert!(PythonRequest::parse("3.12").intersects_requires_python(&requires_python));
assert!(!PythonRequest::parse("3.11").intersects_requires_python(&requires_python));
}

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

// `3` overlaps with `>=3.12` (e.g., 3.12, 3.13, ... are all Python 3)
assert!(PythonRequest::parse("3").intersects_requires_python(&requires_python));
// `2` does not overlap with `>=3.12`
assert!(!PythonRequest::parse("2").intersects_requires_python(&requires_python));
}

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

assert!(PythonRequest::parse(">=3.12,<3.13").intersects_requires_python(&requires_python));
assert!(!PythonRequest::parse(">=3.10,<3.12").intersects_requires_python(&requires_python));
}

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

assert!(
PythonRequest::parse("cpython@>=3.12,<3.13")
.intersects_requires_python(&requires_python)
);
assert!(
!PythonRequest::parse("cpython@>=3.10,<3.12")
.intersects_requires_python(&requires_python)
);
}

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

// Requests without version constraints are always compatible
assert!(PythonRequest::Any.intersects_requires_python(&requires_python));
assert!(PythonRequest::Default.intersects_requires_python(&requires_python));
assert!(
PythonRequest::Implementation(ImplementationName::CPython)
.intersects_requires_python(&requires_python)
);
}
}
112 changes: 80 additions & 32 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1257,48 +1257,96 @@ pub(crate) struct ScriptPython {
}

impl ScriptPython {
/// Determine the [`ScriptPython`] for the current [`Workspace`].
/// Determine the [`ScriptPython`] for the current [`Pep723Script`].
pub(crate) async fn from_request(
python_request: Option<PythonRequest>,
workspace: Option<&Workspace>,
script: Pep723ItemRef<'_>,
no_config: bool,
) -> Result<Self, ProjectError> {
// First, discover a requirement from the workspace
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The diff in this function isn't ideal and it duplicates some of the workspace logic, but I have a follow-up which refactors these implementations to share logic.

let WorkspacePython {
mut source,
mut python_request,
requires_python,
} = WorkspacePython::from_request(
python_request,
workspace,
// Scripts have no groups to hang requires-python settings off of
&DependencyGroupsWithDefaults::none(),
script.path().and_then(Path::parent).unwrap_or(&**CWD),
no_config,
)
.await?;
let script_requires_python = script
.metadata()
.requires_python
.as_ref()
.map(RequiresPython::from_specifiers);

// 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;
let workspace_requires_python = workspace
.map(|workspace| find_requires_python(workspace, &DependencyGroupsWithDefaults::none()))
.transpose()?
.flatten();

let workspace_root = workspace.map(Workspace::install_path);
let project_dir = script.path().and_then(Path::parent).unwrap_or(&**CWD);

let (source, python_request) = if let Some(request) = python_request {
// (1) Explicit request from user
(PythonRequestSource::UserRequest, Some(request))
} 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?
.filter(|file| {
// Ignore version files that are incompatible with the script's `requires-python`
match (file.version(), script_requires_python.as_ref()) {
(Some(request), Some(requires_python)) => {
request.intersects_requires_python(requires_python)
}
Some((
RequiresPython::from_specifiers(requires_python_specifiers),
RequiresPythonSource::Script,
))
} else {
requires_python.map(|requirement| (requirement, RequiresPythonSource::Project))
};
_ => true,
}
})
.filter(|file| {
// Ignore global version files that are incompatible with the workspace `requires-python`
if !file.is_global() {
return true;
}
match (file.version(), workspace_requires_python.as_ref()) {
(Some(request), Some(requires_python)) => {
request.intersects_requires_python(requires_python)
}
_ => true,
}
}) {
// (2) Request from `.python-version`
(
PythonRequestSource::DotPythonVersion(file.clone()),
file.version().cloned(),
)
} 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))
} 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,
))
});
(PythonRequestSource::RequiresPython, request)
};

let requires_python = if let Some(requires_python) = script_requires_python {
Some((requires_python, RequiresPythonSource::Script))
} else {
workspace_requires_python
.map(|requires_python| (requires_python, RequiresPythonSource::Project))
};

if let Some(python_request) = python_request.as_ref() {
debug!("Using Python request {python_request} from {source}");
debug!(
"Using Python request `{}` from {source}",
python_request.to_canonical_string()
);
}

Ok(Self {
Expand Down
Loading