diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index c229c13bd424..1abd3b26a256 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1907,6 +1907,11 @@ pub struct RunArgs { #[arg(long, value_parser = parse_maybe_file_path)] pub with_requirements: Vec>, + /// Run the tool in an isolated virtual environment, rather than leveraging the base environment + /// for the current project, to enforce strict isolation between dependencies. + #[arg(long)] + pub isolated: bool, + /// Assert that the `uv.lock` will remain unchanged. #[arg(long, conflicts_with = "frozen")] pub locked: bool, diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 37d8f318ea77..54c2f4eff2fa 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -41,12 +41,13 @@ pub(crate) async fn run( show_resolution: bool, locked: bool, frozen: bool, + isolated: bool, package: Option, + no_project: bool, extras: ExtrasSpecification, dev: bool, python: Option, settings: ResolverInstallerSettings, - no_project: bool, preview: PreviewMode, python_preference: PythonPreference, python_fetch: PythonFetch, @@ -157,6 +158,8 @@ pub(crate) async fn run( None }; + let temp_dir; + // Discover and sync the base environment. let base_interpreter = if let Some(script_interpreter) = script_interpreter { Some(script_interpreter) @@ -195,17 +198,53 @@ pub(crate) async fn run( ); } - let venv = project::get_or_init_environment( - project.workspace(), - python.as_deref().map(PythonRequest::parse), - python_preference, - python_fetch, - connectivity, - native_tls, - cache, - printer.filter(show_resolution), - ) - .await?; + let venv = if isolated { + // If we're isolating the environment, use an ephemeral virtual environment as the + // base environment for the project. + let interpreter = { + let client_builder = BaseClientBuilder::new() + .connectivity(connectivity) + .native_tls(native_tls); + + // Note we force preview on during `uv run` for now since the entire interface is in preview + PythonInstallation::find_or_fetch( + python.as_deref().map(PythonRequest::parse), + EnvironmentPreference::Any, + python_preference, + python_fetch, + &client_builder, + cache, + Some(&reporter), + ) + .await? + .into_interpreter() + }; + + // Create a virtual environment + temp_dir = cache.environment()?; + uv_virtualenv::create_venv( + temp_dir.path(), + interpreter, + uv_virtualenv::Prompt::None, + false, + false, + false, + )? + } else { + // If we're not isolating the environment, reuse the base environment for the + // project. + project::get_or_init_environment( + project.workspace(), + python.as_deref().map(PythonRequest::parse), + python_preference, + python_fetch, + connectivity, + native_tls, + cache, + printer.filter(show_resolution), + ) + .await? + }; let lock = match project::lock::do_safe_lock( locked, diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 4fbd2ba388e0..454984d20143 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -914,12 +914,13 @@ async fn run_project( args.show_resolution || globals.verbose > 0, args.locked, args.frozen, + args.isolated, args.package, + args.no_project || globals.isolated, args.extras, args.dev, args.python, args.settings, - args.no_project || globals.isolated, globals.preview, globals.python_preference, globals.python_fetch, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 71482dc7eb05..fad3e0d6a43b 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -194,6 +194,7 @@ pub(crate) struct RunSettings { pub(crate) command: ExternalCommand, pub(crate) with: Vec, pub(crate) with_requirements: Vec, + pub(crate) isolated: bool, pub(crate) show_resolution: bool, pub(crate) package: Option, pub(crate) no_project: bool, @@ -215,7 +216,7 @@ impl RunSettings { command, with, with_requirements, - show_resolution, + isolated, locked, frozen, installer, @@ -224,6 +225,7 @@ impl RunSettings { package, no_project, python, + show_resolution, } = args; Self { @@ -240,6 +242,7 @@ impl RunSettings { .into_iter() .filter_map(Maybe::into_option) .collect(), + isolated, show_resolution, package, no_project, diff --git a/crates/uv/tests/workspace.rs b/crates/uv/tests/workspace.rs index edb7991085c4..74d8bf2ba5f0 100644 --- a/crates/uv/tests/workspace.rs +++ b/crates/uv/tests/workspace.rs @@ -498,10 +498,10 @@ fn test_uv_run_with_package_root_workspace() -> Result<()> { uv_snapshot!(context.filters(), universal_windows_filters=true, context .run() .arg("--preview") - .arg("--package") - .arg("albatross") - .arg("check_installed_albatross.py") - .current_dir(&work_dir), @r###" + .arg("--package") + .arg("albatross") + .arg("check_installed_albatross.py") + .current_dir(&work_dir), @r###" success: true exit_code: 0 ----- stdout ----- @@ -519,6 +519,97 @@ fn test_uv_run_with_package_root_workspace() -> Result<()> { Ok(()) } +/// Check that `uv run --isolated` creates isolated virtual environments. +#[test] +fn test_uv_run_isolate() -> Result<()> { + let context = TestContext::new("3.12"); + let work_dir = context.temp_dir.join("albatross-root-workspace"); + + copy_dir_ignore(workspaces_dir().join("albatross-root-workspace"), &work_dir)?; + + let mut filters = context.filters(); + filters.push(( + r"Using Python 3.12.\[X\] interpreter at: .*", + "Using Python 3.12.[X] interpreter at: [PYTHON]", + )); + + // Install the root package. + uv_snapshot!(context.filters(), universal_windows_filters=true, context + .run() + .arg("--preview") + .arg("--package") + .arg("albatross") + .arg("check_installed_albatross.py") + .current_dir(&work_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Success + + ----- stderr ----- + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtualenv at: .venv + Resolved 8 packages in [TIME] + Prepared 7 packages in [TIME] + Installed 7 packages in [TIME] + + albatross==0.1.0 (from file://[TEMP_DIR]/albatross-root-workspace) + + anyio==4.3.0 + + bird-feeder==1.0.0 (from file://[TEMP_DIR]/albatross-root-workspace/packages/bird-feeder) + + idna==3.6 + + seeds==1.0.0 (from file://[TEMP_DIR]/albatross-root-workspace/packages/seeds) + + sniffio==1.3.1 + + tqdm==4.66.2 + "### + ); + + // Run in `bird-feeder`. We shouldn't be able to import `albatross`, but we _can_ due to our + // virtual environment semantics. Specifically, we only make the changes necessary to run a + // given command, so we don't remove `albatross` from the environment. + uv_snapshot!(filters, context + .run() + .arg("--preview") + .arg("--package") + .arg("bird-feeder") + .arg("check_installed_albatross.py") + .current_dir(&work_dir), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Success + + ----- stderr ----- + Resolved 8 packages in [TIME] + Audited 5 packages in [TIME] + "### + ); + + // If we `--isolated`, though, we use an isolated virtual environment, so `albatross` is not + // available. + // TODO(charlie): This should show the resolution output, but `--isolated` is coupled to + // `--no-project` right now. + uv_snapshot!(filters, context + .run() + .arg("--preview") + .arg("--isolated") + .arg("--package") + .arg("bird-feeder") + .arg("check_installed_albatross.py") + .current_dir(&work_dir), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Traceback (most recent call last): + File "[TEMP_DIR]/albatross-root-workspace/check_installed_albatross.py", line 1, in + from albatross import fly + ModuleNotFoundError: No module named 'albatross' + "### + ); + + Ok(()) +} + /// Check that the resolution is the same no matter where in the workspace we are. fn workspace_lock_idempotence(workspace: &str, subdirectories: &[&str]) -> Result<()> { let mut shared_lock = None;