Skip to content

Commit e293c60

Browse files
committed
Allow discovering virtual environments from the first interpreter found on the PATH
1 parent d9907f6 commit e293c60

File tree

3 files changed

+86
-9
lines changed

3 files changed

+86
-9
lines changed

crates/uv-python/src/discovery.rs

+32-5
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ pub enum PythonSource {
193193
DiscoveredEnvironment,
194194
/// An executable was found in the search path i.e. `PATH`
195195
SearchPath,
196+
/// The first executable found in the search path i.e. `PATH`
197+
SearchPathFirst,
196198
/// An executable was found in the Windows registry via PEP 514
197199
Registry,
198200
/// An executable was found in the known Microsoft Store locations
@@ -331,7 +333,14 @@ fn python_executables_from_installed<'a>(
331333

332334
let from_search_path = iter::once_with(move || {
333335
python_executables_from_search_path(version, implementation)
334-
.map(|path| Ok((PythonSource::SearchPath, path)))
336+
.enumerate()
337+
.map(|(i, path)| {
338+
if i == 0 {
339+
Ok((PythonSource::SearchPathFirst, path))
340+
} else {
341+
Ok((PythonSource::SearchPath, path))
342+
}
343+
})
335344
})
336345
.flatten();
337346

@@ -1049,7 +1058,10 @@ pub(crate) fn find_python_installation(
10491058
// If the interpreter has a default executable name, e.g. `python`, and was found on the
10501059
// search path, we consider this opt-in to use it.
10511060
let has_default_executable_name = installation.interpreter.has_default_executable_name()
1052-
&& installation.source == PythonSource::SearchPath;
1061+
&& matches!(
1062+
installation.source,
1063+
PythonSource::SearchPath | PythonSource::SearchPathFirst
1064+
);
10531065

10541066
// If it's a pre-release and pre-releases aren't allowed, skip it — but store it for later
10551067
// since we'll use a pre-release if no other versions are available.
@@ -1601,6 +1613,7 @@ impl PythonSource {
16011613
match self {
16021614
Self::Managed | Self::Registry | Self::MicrosoftStore => false,
16031615
Self::SearchPath
1616+
| Self::SearchPathFirst
16041617
| Self::CondaPrefix
16051618
| Self::BaseCondaPrefix
16061619
| Self::ProvidedPath
@@ -1613,7 +1626,13 @@ impl PythonSource {
16131626
/// Whether an alternative Python implementation from this source can be used without opt-in.
16141627
pub(crate) fn allows_alternative_implementations(self) -> bool {
16151628
match self {
1616-
Self::Managed | Self::Registry | Self::SearchPath | Self::MicrosoftStore => false,
1629+
Self::Managed
1630+
| Self::Registry
1631+
| Self::SearchPath
1632+
// TODO(zanieb): We may want to allow this at some point, but when adding this variant
1633+
// we want compatibility with existing behavior
1634+
| Self::SearchPathFirst
1635+
| Self::MicrosoftStore => false,
16171636
Self::CondaPrefix
16181637
| Self::BaseCondaPrefix
16191638
| Self::ProvidedPath
@@ -1629,15 +1648,20 @@ impl PythonSource {
16291648
/// environment; pragmatically, that's not common and saves us from querying a bunch of system
16301649
/// interpreters for no reason. It seems dubious to consider an interpreter in the `PATH` as a
16311650
/// target virtual environment if it's not discovered through our virtual environment-specific
1632-
/// patterns.
1651+
/// patterns. Instead, we special case the first Python executable found on the `PATH` with
1652+
/// [`PythonSource::SearchPathFirst`], allowing us to check if that's a virtual environment.
1653+
/// This enables targeting the virtual environment with uv by putting its `bin/` on the `PATH`
1654+
/// without setting `VIRTUAL_ENV` — but if there's another interpreter before it we will ignore
1655+
/// it.
16331656
pub(crate) fn is_maybe_virtualenv(self) -> bool {
16341657
match self {
16351658
Self::ProvidedPath
16361659
| Self::ActiveEnvironment
16371660
| Self::DiscoveredEnvironment
16381661
| Self::CondaPrefix
16391662
| Self::BaseCondaPrefix
1640-
| Self::ParentInterpreter => true,
1663+
| Self::ParentInterpreter
1664+
| Self::SearchPathFirst => true,
16411665
Self::Managed | Self::SearchPath | Self::Registry | Self::MicrosoftStore => false,
16421666
}
16431667
}
@@ -1651,6 +1675,7 @@ impl PythonSource {
16511675
| Self::ProvidedPath
16521676
| Self::Managed
16531677
| Self::SearchPath
1678+
| Self::SearchPathFirst
16541679
| Self::Registry
16551680
| Self::MicrosoftStore => true,
16561681
Self::ActiveEnvironment | Self::DiscoveredEnvironment => false,
@@ -2062,6 +2087,7 @@ impl VersionRequest {
20622087
| PythonSource::DiscoveredEnvironment
20632088
| PythonSource::ActiveEnvironment => Self::Any,
20642089
PythonSource::SearchPath
2090+
| PythonSource::SearchPathFirst
20652091
| PythonSource::Registry
20662092
| PythonSource::MicrosoftStore
20672093
| PythonSource::Managed => Self::Default,
@@ -2473,6 +2499,7 @@ impl fmt::Display for PythonSource {
24732499
Self::CondaPrefix | Self::BaseCondaPrefix => f.write_str("conda prefix"),
24742500
Self::DiscoveredEnvironment => f.write_str("virtual environment"),
24752501
Self::SearchPath => f.write_str("search path"),
2502+
Self::SearchPathFirst => f.write_str("first executable in the search path"),
24762503
Self::Registry => f.write_str("registry"),
24772504
Self::MicrosoftStore => f.write_str("Microsoft Store"),
24782505
Self::Managed => f.write_str("managed installations"),

crates/uv-python/src/lib.rs

+4-4
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,7 @@ mod tests {
507507
matches!(
508508
interpreter,
509509
PythonInstallation {
510-
source: PythonSource::SearchPath,
510+
source: PythonSource::SearchPathFirst,
511511
interpreter: _
512512
}
513513
),
@@ -936,7 +936,7 @@ mod tests {
936936
matches!(
937937
python,
938938
PythonInstallation {
939-
source: PythonSource::SearchPath,
939+
source: PythonSource::SearchPathFirst,
940940
interpreter: _
941941
}
942942
),
@@ -2427,7 +2427,7 @@ mod tests {
24272427
matches!(
24282428
python,
24292429
PythonInstallation {
2430-
source: PythonSource::SearchPath,
2430+
source: PythonSource::SearchPathFirst,
24312431
interpreter: _
24322432
}
24332433
),
@@ -2479,7 +2479,7 @@ mod tests {
24792479
matches!(
24802480
python,
24812481
PythonInstallation {
2482-
source: PythonSource::SearchPath,
2482+
source: PythonSource::SearchPathFirst,
24832483
interpreter: _
24842484
}
24852485
),

crates/uv/tests/it/python_find.rs

+50
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,56 @@ fn python_find_venv() {
482482
483483
----- stderr -----
484484
"###);
485+
486+
// Or at the front of the PATH
487+
#[cfg(not(windows))]
488+
uv_snapshot!(context.filters(), context.python_find().env(EnvVars::UV_TEST_PYTHON_PATH, child_dir.join(".venv").join("bin").as_os_str()), @r###"
489+
success: true
490+
exit_code: 0
491+
----- stdout -----
492+
[TEMP_DIR]/child/.venv/[BIN]/python
493+
494+
----- stderr -----
495+
"###);
496+
497+
// This holds even if there are other directories before it in the path, as long as they do
498+
// not contain a Python executable
499+
#[cfg(not(windows))]
500+
{
501+
let path = std::env::join_paths(&[
502+
context.temp_dir.to_path_buf(),
503+
child_dir.join(".venv").join("bin"),
504+
])
505+
.unwrap();
506+
507+
uv_snapshot!(context.filters(), context.python_find().env(EnvVars::UV_TEST_PYTHON_PATH, path.as_os_str()), @r###"
508+
success: true
509+
exit_code: 0
510+
----- stdout -----
511+
[TEMP_DIR]/child/.venv/[BIN]/python
512+
513+
----- stderr -----
514+
"###);
515+
}
516+
517+
// But, if there's an executable _before_ the virtual environment — we prefer that
518+
#[cfg(not(windows))]
519+
{
520+
let path = std::env::join_paths(
521+
std::env::split_paths(&context.python_path())
522+
.chain(std::iter::once(child_dir.join(".venv").join("bin"))),
523+
)
524+
.unwrap();
525+
526+
uv_snapshot!(context.filters(), context.python_find().env(EnvVars::UV_TEST_PYTHON_PATH, path.as_os_str()), @r###"
527+
success: true
528+
exit_code: 0
529+
----- stdout -----
530+
[PYTHON-3.11]
531+
532+
----- stderr -----
533+
"###);
534+
}
485535
}
486536

487537
#[cfg(unix)]

0 commit comments

Comments
 (0)