diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index decd2b107..79dc4c54d 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -2140,6 +2140,14 @@ pub struct RunArgs { #[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 project environment in a separate, ephemeral environment. These + /// dependencies are allowed to conflict with those specified by the project. + #[arg(long)] + pub with_editable: Vec, + /// Run with all packages listed in the given `requirements.txt` files. /// /// The same environment semantics as `--with` apply. diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index b029909e2..27e8ea08d 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -554,7 +554,16 @@ pub(crate) async fn run( eprint!("{report:?}"); return Ok(ExitStatus::Failure); } - Err(err) => return Err(err.into()), + Err(ProjectError::Operation(operations::Error::Named(err))) => { + let err = miette::Report::msg(format!("{err}")) + .context("Invalid `--with` requirement"); + eprint!("{err:?}"); + return Ok(ExitStatus::Failure); + } + + Err(err) => { + return Err(err.into()); + } }; environment.into() diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index 7e55c0626..38a502cb7 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1011,6 +1011,11 @@ async fn run_project( .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 e472c3887..7db2d430e 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -193,6 +193,7 @@ pub(crate) struct RunSettings { pub(crate) dev: bool, pub(crate) command: ExternalCommand, pub(crate) with: Vec, + pub(crate) with_editable: Vec, pub(crate) with_requirements: Vec, pub(crate) isolated: bool, pub(crate) show_resolution: bool, @@ -215,6 +216,7 @@ impl RunSettings { no_dev, command, with, + with_editable, with_requirements, isolated, locked, @@ -238,6 +240,7 @@ impl RunSettings { dev: flag(dev, no_dev).unwrap_or(true), command, 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 433b331c8..eb03005c0 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -7,7 +7,7 @@ use indoc::indoc; use uv_python::PYTHON_VERSION_FILENAME; -use common::{uv_snapshot, TestContext}; +use common::{copy_dir_all, uv_snapshot, TestContext}; mod common; @@ -538,6 +538,141 @@ fn run_with() -> Result<()> { Ok(()) } +#[test] +fn run_with_editable() -> Result<()> { + let context = TestContext::new("3.12"); + + 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 + " + })?; + + // Requesting an editable requirement should install it in a layer. + uv_snapshot!(context.filters(), context.run().arg("--with-editable").arg("./src/black_editable").arg("main.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Prepared 4 packages in [TIME] + Installed 4 packages in [TIME] + + anyio==4.3.0 + + foo==1.0.0 (from file://[TEMP_DIR]/) + + idna==3.6 + + sniffio==1.3.1 + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + black==0.1.0 (from file://[TEMP_DIR]/src/black_editable) + "###); + + // Requesting an editable requirement should install it in a layer, even if it satisfied + uv_snapshot!(context.filters(), context.run().arg("--with-editable").arg("./src/anyio_local").arg("main.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Audited 4 packages in [TIME] + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + anyio==4.3.0+foo (from file://[TEMP_DIR]/src/anyio_local) + "###); + + // Requesting the project itself should use the base environment. + uv_snapshot!(context.filters(), context.run().arg("--with-editable").arg(".").arg("main.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 6 packages in [TIME] + Audited 4 packages in [TIME] + "###); + + // Similarly, an already editable requirement does not require a layer + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.8" + dependencies = ["anyio", "sniffio==1.3.1"] + + [tool.uv.sources] + anyio = { path = "./src/anyio_local", editable = true } + "# + })?; + + uv_snapshot!(context.filters(), context.sync(), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 3 packages in [TIME] + Installed 2 packages in [TIME] + - anyio==4.3.0 + + anyio==4.3.0+foo (from file://[TEMP_DIR]/src/anyio_local) + ~ foo==1.0.0 (from file://[TEMP_DIR]/) + - idna==3.6 + "###); + + uv_snapshot!(context.filters(), context.run().arg("--with-editable").arg("./src/anyio_local").arg("main.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Audited 3 packages in [TIME] + "###); + + // If invalid, we should reference `--with-editable`. + uv_snapshot!(context.filters(), context.run().arg("--with").arg("./foo").arg("main.py"), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + Audited 3 packages in [TIME] + × Invalid `--with` requirement + ╰─▶ Distribution not found at: file://[TEMP_DIR]/foo + "###); + + Ok(()) +} + #[test] fn run_locked() -> Result<()> { let context = TestContext::new("3.12"); diff --git a/docs/reference/cli.md b/docs/reference/cli.md index a97877ae1..9724a9c6c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -322,6 +322,10 @@ uv run [OPTIONS]

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

+
--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 project environment in a separate, ephemeral environment. These dependencies are allowed to conflict with those specified by the project.

+
--with-requirements with-requirements

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

The same environment semantics as --with apply.