diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index c7706a451..e9515a9e1 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -3204,6 +3204,14 @@ pub struct ToolRunArgs { #[arg(long)] pub with: Vec, + /// Run with the given packages installed as editables + /// + /// When used in a project, these dependencies will be layered on top of + /// the uv tool's environment in a separate, ephemeral environment. These + /// dependencies are allowed to conflict with those specified. + #[arg(long)] + pub with_editable: Vec, + /// Run with all packages listed in the given `requirements.txt` files. #[arg(long, value_parser = parse_maybe_file_path)] pub with_requirements: Vec>, diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index a9df02544..8fbc3827f 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -22,7 +22,9 @@ use uv_python::{ EnvironmentPreference, Interpreter, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile, VersionRequest, }; -use uv_requirements::{NamedRequirementsResolver, RequirementsSpecification}; +use uv_requirements::{ + NamedRequirementsError, NamedRequirementsResolver, RequirementsSpecification, +}; use uv_resolver::{ FlatIndex, OptionsBuilder, PythonRequirement, RequiresPython, ResolutionGraph, ResolverMarkers, }; @@ -548,7 +550,7 @@ pub(crate) async fn resolve_names( native_tls: bool, cache: &Cache, printer: Printer, -) -> anyhow::Result> { +) -> Result, NamedRequirementsError> { // Partition the requirements into named and unnamed requirements. let (mut requirements, unnamed): (Vec<_>, Vec<_>) = requirements diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index e30732cf7..cbecf373a 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -663,10 +663,7 @@ pub(crate) async fn run( eprint!("{err:?}"); return Ok(ExitStatus::Failure); } - - Err(err) => { - return Err(err.into()); - } + Err(err) => return Err(err.into()), }; environment.into() diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 66008d36f..ff013d803 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -130,6 +130,11 @@ pub(crate) async fn run( eprint!("{report:?}"); return Ok(ExitStatus::Failure); } + Err(ProjectError::NamedRequirements(err)) => { + let err = miette::Report::msg(format!("{err}")).context("Invalid `--with` requirement"); + eprint!("{err:?}"); + return Ok(ExitStatus::Failure); + } Err(err) => return Err(err.into()), }; diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 8709865b4..d7fc0e06e 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -828,6 +828,11 @@ async fn run(cli: Cli) -> Result { .with .into_iter() .map(RequirementsSource::from_package) + .chain( + args.with_editable + .into_iter() + .map(RequirementsSource::Editable), + ) .chain( args.with_requirements .into_iter() diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 25898e21d..ae06f015c 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -290,6 +290,7 @@ pub(crate) struct ToolRunSettings { pub(crate) command: Option, pub(crate) from: Option, pub(crate) with: Vec, + pub(crate) with_editable: Vec, pub(crate) with_requirements: Vec, pub(crate) isolated: bool, pub(crate) show_resolution: bool, @@ -310,6 +311,7 @@ impl ToolRunSettings { command, from, with, + with_editable, with_requirements, isolated, show_resolution, @@ -341,6 +343,7 @@ impl ToolRunSettings { command, from, with, + with_editable, with_requirements: with_requirements .into_iter() .filter_map(Maybe::into_option) diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index 66f6ec28b..eb13cbc6f 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -810,7 +810,7 @@ fn run_with_editable() -> Result<()> { "###); // If invalid, we should reference `--with-editable`. - uv_snapshot!(context.filters(), context.run().arg("--with").arg("./foo").arg("main.py"), @r###" + uv_snapshot!(context.filters(), context.run().arg("--with-editable").arg("./foo").arg("main.py"), @r###" success: false exit_code: 1 ----- stdout ----- diff --git a/crates/uv/tests/tool_run.rs b/crates/uv/tests/tool_run.rs index 14608fc2f..d92e5e5be 100644 --- a/crates/uv/tests/tool_run.rs +++ b/crates/uv/tests/tool_run.rs @@ -2,7 +2,7 @@ use assert_cmd::prelude::*; use assert_fs::prelude::*; -use common::{uv_snapshot, TestContext}; +use common::{copy_dir_all, uv_snapshot, TestContext}; use indoc::indoc; mod common; @@ -823,6 +823,145 @@ fn tool_run_without_output() { "###); } +#[test] +fn tool_run_with_editable() -> anyhow::Result<()> { + let context = TestContext::new("3.12").with_filtered_counts(); + let tool_dir = context.temp_dir.child("tools"); + let bin_dir = context.temp_dir.child("bin"); + + let anyio_local = context.temp_dir.child("src").child("anyio_local"); + copy_dir_all( + context.workspace_root.join("scripts/packages/anyio_local"), + &anyio_local, + )?; + + let black_editable = context.temp_dir.child("src").child("black_editable"); + copy_dir_all( + context + .workspace_root + .join("scripts/packages/black_editable"), + &black_editable, + )?; + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.8" + dependencies = ["anyio", "sniffio==1.3.1"] + "# + })?; + + let test_script = context.temp_dir.child("main.py"); + test_script.write_str(indoc! { r" + import sniffio + " + })?; + + uv_snapshot!(context.filters(), context.tool_run() + .arg("--with-editable") + .arg("./src/black_editable") + .arg("--with") + .arg("iniconfig") + .arg("flask") + .arg("--version") + .env("UV_TOOL_DIR", tool_dir.as_os_str()) + .env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + Flask 3.0.2 + Werkzeug 3.0.1 + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + black==0.1.0 (from file://[TEMP_DIR]/src/black_editable) + + blinker==1.7.0 + + click==8.1.7 + + flask==3.0.2 + + iniconfig==2.0.0 + + itsdangerous==2.1.2 + + jinja2==3.1.3 + + markupsafe==2.1.5 + + werkzeug==3.0.1 + "###); + + // Requesting an editable requirement should install it in a layer, even if it satisfied + uv_snapshot!(context.filters(), context.tool_run().arg("--with-editable").arg("./src/anyio_local").arg("flask").arg("--version").env("UV_TOOL_DIR", tool_dir.as_os_str()).env("XDG_BIN_HOME", bin_dir.as_os_str()) + + , @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + Flask 3.0.2 + Werkzeug 3.0.1 + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + anyio==4.3.0+foo (from file://[TEMP_DIR]/src/anyio_local) + + blinker==1.7.0 + + click==8.1.7 + + flask==3.0.2 + + itsdangerous==2.1.2 + + jinja2==3.1.3 + + markupsafe==2.1.5 + + werkzeug==3.0.1 + "###); + + // Requesting the project itself should use a new environment. + uv_snapshot!(context.filters(), context.tool_run().arg("--with-editable").arg(".").arg("flask").arg("--version").env("UV_TOOL_DIR", tool_dir.as_os_str()).env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: true + exit_code: 0 + ----- stdout ----- + Python 3.12.[X] + Flask 3.0.2 + Werkzeug 3.0.1 + + ----- stderr ----- + Resolved [N] packages in [TIME] + Prepared [N] packages in [TIME] + Installed [N] packages in [TIME] + + anyio==4.3.0 + + blinker==1.7.0 + + click==8.1.7 + + flask==3.0.2 + + foo==1.0.0 (from file://[TEMP_DIR]/) + + idna==3.6 + + itsdangerous==2.1.2 + + jinja2==3.1.3 + + markupsafe==2.1.5 + + sniffio==1.3.1 + + werkzeug==3.0.1 + "###); + + // If invalid, we should reference `--with`. + uv_snapshot!(context.filters(), context + .tool_run() + .arg("--with") + .arg("./foo") + .arg("flask") + .arg("--version") + .env("UV_TOOL_DIR", tool_dir + .as_os_str()).env("XDG_BIN_HOME", bin_dir.as_os_str()), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Invalid `--with` requirement + ╰─▶ Distribution not found at: file://[TEMP_DIR]/foo + "###); + + Ok(()) +} + #[test] fn warn_no_executables_found() { let context = TestContext::new("3.12").with_filtered_exe_suffix(); diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 909d0cad5..a7ee29fd4 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -2593,6 +2593,10 @@ uv tool run [OPTIONS] [COMMAND]
--with with

Run with the given packages installed

+
--with-editable with-editable

Run with the given packages installed as editables

+ +

When used in a project, these dependencies will be layered on top of the uv tool’s environment in a separate, ephemeral environment. These dependencies are allowed to conflict with those specified.

+
--with-requirements with-requirements

Run with all packages listed in the given requirements.txt files