Add --isolated support to uv run (#5471)

## Summary

The culmination of #4730. We now have `uv run --isolated` which always
uses a fresh environment (but includes the workspace dependencies as
needed). This enables you to test with strict isolation (e.g., `uv run
--isolated -p foo` will ensure that `foo` is unable to import anything
that isn't an actual dependency).

Closes #5430.
This commit is contained in:
Charlie Marsh 2024-07-30 15:27:47 -04:00 committed by GitHub
parent ff3bcbb639
commit 67b3bfa213
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 157 additions and 18 deletions

View file

@ -1907,6 +1907,11 @@ pub struct RunArgs {
#[arg(long, value_parser = parse_maybe_file_path)]
pub with_requirements: Vec<Maybe<PathBuf>>,
/// 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,

View file

@ -41,12 +41,13 @@ pub(crate) async fn run(
show_resolution: bool,
locked: bool,
frozen: bool,
isolated: bool,
package: Option<PackageName>,
no_project: bool,
extras: ExtrasSpecification,
dev: bool,
python: Option<String>,
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,

View file

@ -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,

View file

@ -194,6 +194,7 @@ pub(crate) struct RunSettings {
pub(crate) command: ExternalCommand,
pub(crate) with: Vec<String>,
pub(crate) with_requirements: Vec<PathBuf>,
pub(crate) isolated: bool,
pub(crate) show_resolution: bool,
pub(crate) package: Option<PackageName>,
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,

View file

@ -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 <module>
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;