Skip to content

Commit 5a9c5a3

Browse files
committed
Consolidate requires-python conversion to PythonRequest
1 parent edb1744 commit 5a9c5a3

File tree

7 files changed

+155
-39
lines changed

7 files changed

+155
-39
lines changed

crates/uv-distribution-types/src/requires_python.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,11 @@ impl RequiresPython {
279279
&self.specifiers
280280
}
281281

282+
/// Consumes `self` and returns the [`VersionSpecifiers`] for the `Requires-Python` specifier.
283+
pub fn into_specifiers(self) -> VersionSpecifiers {
284+
self.specifiers
285+
}
286+
282287
/// Returns `true` if the `Requires-Python` specifier is unbounded.
283288
pub fn is_unbounded(&self) -> bool {
284289
self.range.lower().as_ref() == Bound::Unbounded

crates/uv-python/src/discovery.rs

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1749,6 +1749,18 @@ impl PythonVariant {
17491749
}
17501750
}
17511751
impl PythonRequest {
1752+
/// Create a request from a `Requires-Python` constraint.
1753+
pub fn from_requires_python(requires_python: RequiresPython) -> Option<Self> {
1754+
if requires_python.is_unbounded() {
1755+
return None;
1756+
}
1757+
1758+
Some(Self::Version(VersionRequest::from_specifiers(
1759+
requires_python.into_specifiers(),
1760+
PythonVariant::Default,
1761+
)))
1762+
}
1763+
17521764
/// Create a request from a string.
17531765
///
17541766
/// This cannot fail, which means weird inputs will be parsed as [`PythonRequest::File`] or
@@ -2621,6 +2633,22 @@ impl fmt::Display for ExecutableName {
26212633
}
26222634

26232635
impl VersionRequest {
2636+
/// Create a [`VersionRequest`] from [`VersionSpecifiers`].
2637+
///
2638+
/// If the specifiers consist of a single `==` constraint, the version is parsed as a
2639+
/// concrete version request (e.g., `MajorMinorPatch`) rather than a range. This ensures that
2640+
/// version-specific executable names (like `python3.12`) are included during discovery.
2641+
pub fn from_specifiers(specifiers: VersionSpecifiers, variant: PythonVariant) -> Self {
2642+
if let [specifier] = specifiers.iter().as_slice() {
2643+
if specifier.operator() == &uv_pep440::Operator::Equal {
2644+
if let Ok(request) = Self::from_str(&specifier.version().to_string()) {
2645+
return request;
2646+
}
2647+
}
2648+
}
2649+
Self::Range(specifiers, variant)
2650+
}
2651+
26242652
/// Drop any patch or prerelease information from the version request.
26252653
#[must_use]
26262654
pub fn only_minor(self) -> Self {
@@ -3324,12 +3352,7 @@ fn parse_version_specifiers_request(
33243352
if specifiers.is_empty() {
33253353
return Err(Error::InvalidVersionRequest(s.to_string()));
33263354
}
3327-
if let [specifier] = specifiers.iter().as_slice() {
3328-
if specifier.operator() == &uv_pep440::Operator::Equal {
3329-
return VersionRequest::from_str(&specifier.version().to_string());
3330-
}
3331-
}
3332-
Ok(VersionRequest::Range(specifiers, variant))
3355+
Ok(VersionRequest::from_specifiers(specifiers, variant))
33333356
}
33343357

33353358
impl From<&PythonVersion> for VersionRequest {
@@ -4136,6 +4159,71 @@ mod tests {
41364159
VersionRequest::from_str("3.13tt"),
41374160
Err(Error::InvalidVersionRequest(_))
41384161
));
4162+
4163+
// `==` specifiers are parsed as concrete version requests via `from_specifiers`
4164+
assert_eq!(
4165+
VersionRequest::from_str("==3.12").unwrap(),
4166+
VersionRequest::MajorMinor(3, 12, PythonVariant::Default)
4167+
);
4168+
assert_eq!(
4169+
VersionRequest::from_str("==3.12.1").unwrap(),
4170+
VersionRequest::MajorMinorPatch(3, 12, 1, PythonVariant::Default)
4171+
);
4172+
}
4173+
4174+
#[test]
4175+
fn version_request_from_specifiers() {
4176+
// A single `==` specifier is parsed as a concrete version request
4177+
assert_eq!(
4178+
VersionRequest::from_specifiers(
4179+
VersionSpecifiers::from_str("==3.12").unwrap(),
4180+
PythonVariant::Default
4181+
),
4182+
VersionRequest::MajorMinor(3, 12, PythonVariant::Default)
4183+
);
4184+
assert_eq!(
4185+
VersionRequest::from_specifiers(
4186+
VersionSpecifiers::from_str("==3.12.1").unwrap(),
4187+
PythonVariant::Default
4188+
),
4189+
VersionRequest::MajorMinorPatch(3, 12, 1, PythonVariant::Default)
4190+
);
4191+
4192+
// Wildcard `==` specifiers remain as ranges
4193+
assert_eq!(
4194+
VersionRequest::from_specifiers(
4195+
VersionSpecifiers::from_str("==3.12.*").unwrap(),
4196+
PythonVariant::Default
4197+
),
4198+
VersionRequest::Range(
4199+
VersionSpecifiers::from_str("==3.12.*").unwrap(),
4200+
PythonVariant::Default
4201+
)
4202+
);
4203+
4204+
// Range specifiers remain as ranges
4205+
assert_eq!(
4206+
VersionRequest::from_specifiers(
4207+
VersionSpecifiers::from_str(">=3.12").unwrap(),
4208+
PythonVariant::Default
4209+
),
4210+
VersionRequest::Range(
4211+
VersionSpecifiers::from_str(">=3.12").unwrap(),
4212+
PythonVariant::Default
4213+
)
4214+
);
4215+
4216+
// Multi-specifier constraints remain as ranges
4217+
assert_eq!(
4218+
VersionRequest::from_specifiers(
4219+
VersionSpecifiers::from_str(">=3.12,<3.14").unwrap(),
4220+
PythonVariant::Default
4221+
),
4222+
VersionRequest::Range(
4223+
VersionSpecifiers::from_str(">=3.12,<3.14").unwrap(),
4224+
PythonVariant::Default
4225+
)
4226+
);
41394227
}
41404228

41414229
#[test]

crates/uv/src/commands/build_frontend.rs

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ use uv_distribution_filename::{
2424
};
2525
use uv_distribution_types::{
2626
ConfigSettings, DependencyMetadata, ExtraBuildVariables, Index, IndexLocations,
27-
PackageConfigSettings, Requirement, RequiresPython, SourceDist,
27+
PackageConfigSettings, Requirement, SourceDist,
2828
};
2929
use uv_fs::{Simplified, relative_to};
3030
use uv_install_wheel::LinkMode;
@@ -33,8 +33,7 @@ use uv_pep440::Version;
3333
use uv_preview::Preview;
3434
use uv_python::{
3535
EnvironmentPreference, PythonDownloads, PythonEnvironment, PythonInstallation,
36-
PythonPreference, PythonRequest, PythonVariant, PythonVersionFile, VersionFileDiscoveryOptions,
37-
VersionRequest,
36+
PythonPreference, PythonRequest, PythonVersionFile, VersionFileDiscoveryOptions,
3837
};
3938
use uv_requirements::RequirementsSource;
4039
use uv_resolver::{ExcludeNewer, FlatIndex};
@@ -527,14 +526,7 @@ async fn build_package(
527526
if let Ok(workspace) = workspace {
528527
let groups = DependencyGroupsWithDefaults::none();
529528
interpreter_request = find_requires_python(workspace, &groups)?
530-
.as_ref()
531-
.map(RequiresPython::specifiers)
532-
.map(|specifiers| {
533-
PythonRequest::Version(VersionRequest::Range(
534-
specifiers.clone(),
535-
PythonVariant::Default,
536-
))
537-
});
529+
.and_then(PythonRequest::from_requires_python);
538530
}
539531
}
540532

crates/uv/src/commands/project/init.rs

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -652,10 +652,8 @@ async fn determine_requires_python(
652652
.flatten()
653653
{
654654
// (3) `requires-python` from the workspace
655-
let python_request = PythonRequest::Version(VersionRequest::Range(
656-
requires_python.specifiers().clone(),
657-
PythonVariant::Default,
658-
));
655+
let python_request = PythonRequest::from_requires_python(requires_python.clone())
656+
.unwrap_or(PythonRequest::Default);
659657

660658
// Pin to the minor version.
661659
let python_pin = if pin_python {

crates/uv/src/commands/project/mod.rs

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1224,14 +1224,8 @@ impl WorkspacePython {
12241224
} else {
12251225
// (3) `requires-python` in `pyproject.toml`
12261226
let request = requires_python
1227-
.as_ref()
1228-
.map(RequiresPython::specifiers)
1229-
.map(|specifiers| {
1230-
PythonRequest::Version(VersionRequest::Range(
1231-
specifiers.clone(),
1232-
PythonVariant::Default,
1233-
))
1234-
});
1227+
.clone()
1228+
.and_then(PythonRequest::from_requires_python);
12351229
let source = PythonRequestSource::RequiresPython;
12361230
(source, request)
12371231
};
@@ -1325,22 +1319,16 @@ impl ScriptPython {
13251319
)
13261320
} else if let Some(specifiers) = script.metadata().requires_python.as_ref() {
13271321
// (3) `requires-python` from script metadata
1328-
let request = PythonRequest::Version(VersionRequest::Range(
1322+
let request = PythonRequest::Version(VersionRequest::from_specifiers(
13291323
specifiers.clone(),
13301324
PythonVariant::Default,
13311325
));
13321326
(PythonRequestSource::RequiresPython, Some(request))
13331327
} else {
13341328
// (4) `requires-python` from workspace `pyproject.toml`
13351329
let request = workspace_requires_python
1336-
.as_ref()
1337-
.map(RequiresPython::specifiers)
1338-
.map(|specifiers| {
1339-
PythonRequest::Version(VersionRequest::Range(
1340-
specifiers.clone(),
1341-
PythonVariant::Default,
1342-
))
1343-
});
1330+
.clone()
1331+
.and_then(PythonRequest::from_requires_python);
13441332
(PythonRequestSource::RequiresPython, request)
13451333
};
13461334

crates/uv/src/commands/tool/common.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ pub(crate) async fn refine_interpreter(
130130
Bound::Unbounded => unreachable!("`requires-python` should never be unbounded"),
131131
};
132132

133-
let requires_python_request = PythonRequest::Version(VersionRequest::Range(
133+
let requires_python_request = PythonRequest::Version(VersionRequest::from_specifiers(
134134
VersionSpecifiers::from_iter([lower_bound, upper_bound]),
135135
PythonVariant::default(),
136136
));

crates/uv/tests/it/python_find.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1536,3 +1536,48 @@ fn python_find_equal() {
15361536
----- stderr -----
15371537
"###);
15381538
}
1539+
1540+
/// When `requires-python` uses `==`, we should find the correct interpreter even
1541+
/// when only a version-specific executable (e.g., `python3.12`) is available.
1542+
///
1543+
/// This is a regression test for <https://github.com/astral-sh/uv/issues/9695>.
1544+
/// The `==` specifier must be converted to a concrete `VersionRequest` (e.g., `MajorMinor`)
1545+
/// so that version-specific executable names like `python3.12` are included during discovery.
1546+
#[test]
1547+
#[cfg(unix)]
1548+
fn python_find_project_requires_python_equal() {
1549+
let context = uv_test::test_context_with_versions!(&["3.11", "3.12"]);
1550+
1551+
// Set up a directory where the Python 3.12 executable is named `python3.12` (not `python3`).
1552+
// This simulates the scenario from the original issue where `python3` points to a different
1553+
// version and only `python3.12` is available for the desired version.
1554+
let child = context.temp_dir.child("child");
1555+
child.create_dir_all().unwrap();
1556+
1557+
let python_3_12 = &context.python_versions.last().unwrap().1;
1558+
std::os::unix::fs::symlink(python_3_12, child.join("python3.12")).unwrap();
1559+
1560+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
1561+
pyproject_toml
1562+
.write_str(indoc! {r#"
1563+
[project]
1564+
name = "project"
1565+
version = "0.1.0"
1566+
requires-python = "==3.12"
1567+
dependencies = []
1568+
"#})
1569+
.unwrap();
1570+
1571+
// Without the `==` special-casing, this would only search for `python3` and `python`,
1572+
// missing the `python3.12` executable entirely.
1573+
uv_snapshot!(context.filters(), context.python_find()
1574+
.env(EnvVars::UV_TEST_PYTHON_PATH, child.path()), @r#"
1575+
success: true
1576+
exit_code: 0
1577+
----- stdout -----
1578+
[TEMP_DIR]/child/python3.12
1579+
1580+
----- stderr -----
1581+
warning: The resolved Python interpreter (Python 3.12.[X]) is incompatible with the project's Python requirement: `==3.12` (from `project.requires-python`)
1582+
"#);
1583+
}

0 commit comments

Comments
 (0)