diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index b6b0ecbcf..729a93c09 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3063,6 +3063,25 @@ pub struct PythonFindArgs { /// directory or parent directories will be used. #[arg(long, alias = "no_workspace")] pub no_project: bool, + + /// Only find system Python interpreters. + /// + /// By default, uv will report the first Python interpreter it would use, including those in an + /// active virtual environment or a virtual environment in the current working directory or any + /// parent directory. + /// + /// The `--system` option instructs uv to skip virtual environment Python interpreters and + /// restrict its search to the system path. + #[arg( + long, + env = "UV_SYSTEM_PYTHON", + value_parser = clap::builder::BoolishValueParser::new(), + overrides_with("no_system") + )] + pub system: bool, + + #[arg(long, overrides_with("system"), hide = true)] + pub no_system: bool, } #[derive(Args)] diff --git a/crates/uv/src/commands/python/find.rs b/crates/uv/src/commands/python/find.rs index a1384f677..e17123938 100644 --- a/crates/uv/src/commands/python/find.rs +++ b/crates/uv/src/commands/python/find.rs @@ -18,9 +18,16 @@ pub(crate) async fn find( request: Option, no_project: bool, no_config: bool, + system: bool, python_preference: PythonPreference, cache: &Cache, ) -> Result { + let environment_preference = if system { + EnvironmentPreference::OnlySystem + } else { + EnvironmentPreference::Any + }; + // (1) Explicit request from user let mut request = request.map(|request| PythonRequest::parse(&request)); @@ -56,12 +63,15 @@ pub(crate) async fn find( let python = PythonInstallation::find( &request.unwrap_or_default(), - EnvironmentPreference::OnlySystem, + environment_preference, python_preference, cache, )?; - println!("{}", python.interpreter().sys_executable().user_display()); + println!( + "{}", + uv_fs::absolutize_path(python.interpreter().sys_executable())?.simplified_display() + ); Ok(ExitStatus::Success) } diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index c119b9080..5a2db2468 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -943,6 +943,7 @@ async fn run(cli: Cli) -> Result { args.request, args.no_project, cli.no_config, + args.system, globals.python_preference, &cache, ) diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index ad2b5b2a3..3284a3ba1 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -564,6 +564,7 @@ impl PythonUninstallSettings { pub(crate) struct PythonFindSettings { pub(crate) request: Option, pub(crate) no_project: bool, + pub(crate) system: bool, } impl PythonFindSettings { @@ -573,11 +574,14 @@ impl PythonFindSettings { let PythonFindArgs { request, no_project, + system, + no_system, } = args; Self { request, no_project, + system: flag(system, no_system).unwrap_or_default(), } } } diff --git a/crates/uv/tests/common/mod.rs b/crates/uv/tests/common/mod.rs index de3558354..b33c09e11 100644 --- a/crates/uv/tests/common/mod.rs +++ b/crates/uv/tests/common/mod.rs @@ -134,7 +134,8 @@ impl TestContext { self } - /// Add extra standard filtering for Python executable names. + /// Add extra standard filtering for Python executable names, e.g., stripping version number + /// and `.exe` suffixes. #[must_use] pub fn with_filtered_python_names(mut self) -> Self { if cfg!(windows) { diff --git a/crates/uv/tests/python_find.rs b/crates/uv/tests/python_find.rs index 7b27406a4..92ce40053 100644 --- a/crates/uv/tests/python_find.rs +++ b/crates/uv/tests/python_find.rs @@ -1,7 +1,8 @@ #![cfg(all(feature = "python", feature = "pypi"))] -use assert_fs::fixture::FileWriteStr; use assert_fs::prelude::PathChild; +use assert_fs::{fixture::FileWriteStr, prelude::PathCreateDir}; +use fs_err::remove_dir_all; use indoc::indoc; use common::{uv_snapshot, TestContext}; @@ -21,7 +22,7 @@ fn python_find() { ----- stdout ----- ----- stderr ----- - error: No interpreter found in system path or `py` launcher + error: No interpreter found in virtual environments, system path, or `py` launcher "###); } else { uv_snapshot!(context.filters(), context.python_find().env("UV_TEST_PYTHON_PATH", ""), @r###" @@ -30,7 +31,7 @@ fn python_find() { ----- stdout ----- ----- stderr ----- - error: No interpreter found in system path + error: No interpreter found in virtual environments or system path "###); } @@ -117,7 +118,7 @@ fn python_find() { ----- stdout ----- ----- stderr ----- - error: No interpreter found for PyPy in system path or `py` launcher + error: No interpreter found for PyPy in virtual environments, system path, or `py` launcher "###); } else { uv_snapshot!(context.filters(), context.python_find().arg("pypy"), @r###" @@ -126,7 +127,7 @@ fn python_find() { ----- stdout ----- ----- stderr ----- - error: No interpreter found for PyPy in system path + error: No interpreter found for PyPy in virtual environments or system path "###); } @@ -243,3 +244,149 @@ fn python_find_project() { ----- stderr ----- "###); } + +#[test] +fn python_find_venv() { + let context: TestContext = TestContext::new_with_versions(&["3.11", "3.12"]) + // Enable additional filters for Windows compatibility + .with_filtered_exe_suffix() + .with_filtered_python_names() + .with_filtered_virtualenv_bin(); + + // Create a virtual environment + uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.12").arg("-q"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "###); + + // We should find it first + // TODO(zanieb): On Windows, this has in a different display path for virtual environments which + // is super annoying and requires some changes to how we represent working directories in the + // test context to resolve. + #[cfg(not(windows))] + uv_snapshot!(context.filters(), context.python_find(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [VENV]/[BIN]/python + + ----- stderr ----- + "###); + + // Even if the `VIRTUAL_ENV` is not set (the test context includes this by default) + #[cfg(not(windows))] + uv_snapshot!(context.filters(), context.python_find().env_remove("VIRTUAL_ENV"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [VENV]/[BIN]/python + + ----- stderr ----- + "###); + + let child_dir = context.temp_dir.child("child"); + child_dir.create_dir_all().unwrap(); + + // Unless the system flag is passed + uv_snapshot!(context.filters(), context.python_find().arg("--system"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.11] + + ----- stderr ----- + "###); + + // Or, `UV_SYSTEM_PYTHON` is set + uv_snapshot!(context.filters(), context.python_find().env("UV_SYSTEM_PYTHON", "1"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.11] + + ----- stderr ----- + "###); + + // Unless, `--no-system` is included + // TODO(zanieb): Report this as a bug upstream — this should be allowed. + uv_snapshot!(context.filters(), context.python_find().arg("--no-system").env("UV_SYSTEM_PYTHON", "1"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: the argument '--no-system' cannot be used with '--system' + + Usage: uv python find --cache-dir [CACHE_DIR] [REQUEST] + + For more information, try '--help'. + "###); + + // We should find virtual environments from a child directory + #[cfg(not(windows))] + uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir).env_remove("VIRTUAL_ENV"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [VENV]/[BIN]/python + + ----- stderr ----- + "###); + + // A virtual environment in the child directory takes precedence over the parent + uv_snapshot!(context.filters(), context.venv().arg("--python").arg("3.11").arg("-q").current_dir(&child_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + "###); + + #[cfg(not(windows))] + uv_snapshot!(context.filters(), context.python_find().current_dir(&child_dir).env_remove("VIRTUAL_ENV"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [TEMP_DIR]/child/.venv/[BIN]/python + + ----- stderr ----- + "###); + + // But if we delete the parent virtual environment + remove_dir_all(context.temp_dir.child(".venv")).unwrap(); + + // And query from there... we should not find the child virtual environment + uv_snapshot!(context.filters(), context.python_find(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [PYTHON-3.11] + + ----- stderr ----- + "###); + + // Unless, it is requested by path + #[cfg(not(windows))] + uv_snapshot!(context.filters(), context.python_find().arg("child/.venv"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [TEMP_DIR]/child/.venv/[BIN]/python + + ----- stderr ----- + "###); + + // Or activated via `VIRTUAL_ENV` + #[cfg(not(windows))] + uv_snapshot!(context.filters(), context.python_find().env("VIRTUAL_ENV", child_dir.join(".venv").as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + [TEMP_DIR]/child/.venv/[BIN]/python + + ----- stderr ----- + "###); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 7d314747c..f93621a70 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -3233,6 +3233,12 @@ uv python find [OPTIONS] [REQUEST]
--quiet, -q

Do not print any output

+
--system

Only find system Python interpreters.

+ +

By default, uv will report the first Python interpreter it would use, including those in an active virtual environment or a virtual environment in the current working directory or any parent directory.

+ +

The --system option instructs uv to skip virtual environment Python interpreters and restrict its search to the system path.

+
--verbose, -v

Use verbose output.

You can configure fine-grained logging using the RUST_LOG environment variable. (<https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#directives>)