diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 137de506e..e7ad0f07c 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1847,6 +1847,12 @@ pub struct RunArgs { #[arg(long)] pub with: Vec, + /// Run with all packages listed in the given `requirements.txt` files. + /// + /// Using `pyproject.toml`, `setup.py`, or `setup.cfg` files is not allowed. + #[arg(long, value_parser = parse_maybe_file_path)] + pub with_requirements: Vec>, + /// Assert that the `uv.lock` will remain unchanged. #[arg(long, conflicts_with = "frozen")] pub locked: bool, diff --git a/crates/uv-requirements/src/specification.rs b/crates/uv-requirements/src/specification.rs index 09d8e386e..014f839af 100644 --- a/crates/uv-requirements/src/specification.rs +++ b/crates/uv-requirements/src/specification.rs @@ -336,4 +336,9 @@ impl RequirementsSpecification { ..Self::default() } } + + /// Return true if the specification does not include any requirements to install. + pub fn is_empty(&self) -> bool { + self.requirements.is_empty() && self.source_trees.is_empty() && self.overrides.is_empty() + } } diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index a05e665d8..557e5d7bb 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -239,7 +239,7 @@ pub(crate) async fn add( &VirtualProject::Project(project), &venv, &lock, - extras, + &extras, dev, Modifications::Sufficient, settings.as_ref().into(), diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 2fca803f9..d6301084c 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -645,7 +645,11 @@ pub(crate) async fn update_environment( // Check if the current environment satisfies the requirements let site_packages = SitePackages::from_environment(&venv)?; - if spec.source_trees.is_empty() && reinstall.is_none() && upgrade.is_none() { + if spec.source_trees.is_empty() + && reinstall.is_none() + && upgrade.is_none() + && spec.overrides.is_empty() + { match site_packages.satisfies(&spec.requirements, &spec.constraints)? { // If the requirements are already satisfied, we're done. SatisfiesResult::Fresh { diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index b771f4ad0..1f2b643cf 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -132,7 +132,7 @@ pub(crate) async fn remove( &VirtualProject::Project(project), &venv, &lock, - extras, + &extras, dev, Modifications::Exact, settings.as_ref().into(), diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 3d75e588f..601753a2c 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1,9 +1,9 @@ use std::borrow::Cow; use std::ffi::OsString; use std::fmt::Write; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use itertools::Itertools; use owo_colors::OwoColorize; use tokio::process::Command; @@ -59,6 +59,28 @@ pub(crate) async fn run( warn_user_once!("`uv run` is experimental and may change without warning"); } + // These cases seem quite complex because (in theory) they should change the "current package". + // Let's ban them entirely for now. + for source in &requirements { + match source { + RequirementsSource::PyprojectToml(_) => { + bail!("Adding requirements from a `pyproject.toml` is not supported in `uv run`"); + } + RequirementsSource::SetupPy(_) => { + bail!("Adding requirements from a `setup.py` is not supported in `uv run`"); + } + RequirementsSource::SetupCfg(_) => { + bail!("Adding requirements from a `setup.cfg` is not supported in `uv run`"); + } + RequirementsSource::RequirementsTxt(path) => { + if path == Path::new("-") { + bail!("Reading requirements from stdin is not supported in `uv run`"); + } + } + _ => {} + } + } + // Parse the input command. let command = RunCommand::from(command); @@ -216,7 +238,7 @@ pub(crate) async fn run( &project, &venv, &lock, - extras, + &extras, dev, Modifications::Sufficient, settings.as_ref().into(), @@ -264,7 +286,7 @@ pub(crate) async fn run( ); } - // Read the `--with` requirements. + // Read the requirements. let spec = if requirements.is_empty() { None } else { @@ -282,6 +304,7 @@ pub(crate) async fn run( // any `--with` requirements, and we already have a base environment, then there's no need to // create an additional environment. let skip_ephemeral = base_interpreter.as_ref().is_some_and(|base_interpreter| { + // No additional requirements. let Some(spec) = spec.as_ref() else { return true; }; @@ -364,35 +387,28 @@ pub(crate) async fn run( false, )?; - if requirements.is_empty() { - Some(venv) - } else { - debug!("Syncing ephemeral requirements"); - - let client_builder = BaseClientBuilder::new() - .connectivity(connectivity) - .native_tls(native_tls); - - let spec = - RequirementsSpecification::from_simple_sources(&requirements, &client_builder) - .await?; - - // Install the ephemeral requirements. - Some( - project::update_environment( - venv, - spec, - &settings, - &state, - preview, - connectivity, - concurrency, - native_tls, - cache, - printer, + match spec { + None => Some(venv), + Some(spec) if spec.is_empty() => Some(venv), + Some(spec) => { + debug!("Syncing ephemeral requirements"); + // Install the ephemeral requirements. + Some( + project::update_environment( + venv, + spec, + &settings, + &state, + preview, + connectivity, + concurrency, + native_tls, + cache, + printer, + ) + .await?, ) - .await?, - ) + } } }; diff --git a/crates/uv/src/commands/project/sync.rs b/crates/uv/src/commands/project/sync.rs index ddd40a78e..c0f74f5ee 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -96,7 +96,7 @@ pub(crate) async fn sync( &project, &venv, &lock, - extras, + &extras, dev, modifications, settings.as_ref().into(), @@ -118,7 +118,7 @@ pub(super) async fn do_sync( project: &VirtualProject, venv: &PythonEnvironment, lock: &Lock, - extras: ExtrasSpecification, + extras: &ExtrasSpecification, dev: bool, modifications: Modifications, settings: InstallerSettingsRef<'_>, @@ -164,7 +164,7 @@ pub(super) async fn do_sync( let tags = venv.interpreter().tags()?; // Read the lockfile. - let resolution = lock.to_resolution(project, markers, tags, &extras, &dev)?; + let resolution = lock.to_resolution(project, markers, tags, extras, &dev)?; // Initialize the registry client. let client = RegistryClientBuilder::new(cache.clone()) diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index a9d9d1691..ef0564d31 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -859,6 +859,11 @@ async fn run_project( .with .into_iter() .map(RequirementsSource::from_package) + .chain( + args.with_requirements + .into_iter() + .map(RequirementsSource::from_requirements_file), + ) .collect::>(); commands::run( diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index 2ee565f6c..db70e980e 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -187,6 +187,7 @@ pub(crate) struct RunSettings { pub(crate) dev: bool, pub(crate) command: ExternalCommand, pub(crate) with: Vec, + pub(crate) with_requirements: Vec, pub(crate) package: Option, pub(crate) python: Option, pub(crate) refresh: Refresh, @@ -207,6 +208,7 @@ impl RunSettings { no_dev, command, with, + with_requirements, installer, build, refresh, @@ -224,6 +226,10 @@ impl RunSettings { dev: flag(dev, no_dev).unwrap_or(true), command, with, + with_requirements: with_requirements + .into_iter() + .filter_map(Maybe::into_option) + .collect(), package, python, refresh: Refresh::from(refresh), diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index 515c10c33..815d98661 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -2,7 +2,7 @@ use anyhow::Result; use assert_cmd::assert::OutputAssertExt; -use assert_fs::prelude::*; +use assert_fs::{fixture::ChildPath, prelude::*}; use indoc::indoc; use common::{uv_snapshot, TestContext}; @@ -541,3 +541,180 @@ fn run_frozen() -> Result<()> { Ok(()) } + +#[test] +fn run_empty_requirements_txt() -> Result<()> { + let context = TestContext::new("3.12"); + + 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 + " + })?; + + let requirements_txt = + ChildPath::new(context.temp_dir.canonicalize()?.join("requirements.txt")); + requirements_txt.touch()?; + + // The project environment is synced on the first invocation. + uv_snapshot!(context.filters(), context.run().arg("--with-requirements").arg(requirements_txt.as_os_str()).arg("main.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv run` is experimental and may change without warning + 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 + warning: Requirements file requirements.txt does not contain any dependencies + "###); + + // Then reused in subsequent invocations + uv_snapshot!(context.filters(), context.run().arg("--with-requirements").arg(requirements_txt.as_os_str()).arg("main.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv run` is experimental and may change without warning + Resolved 6 packages in [TIME] + Audited 4 packages in [TIME] + warning: Requirements file requirements.txt does not contain any dependencies + "###); + + Ok(()) +} + +#[test] +fn run_requirements_txt() -> Result<()> { + let context = TestContext::new("3.12"); + + 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 unsatisfied requirement should install it. + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("iniconfig")?; + + uv_snapshot!(context.filters(), context.run().arg("--with-requirements").arg(requirements_txt.as_os_str()).arg("main.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv run` is experimental and may change without warning + 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] + + iniconfig==2.0.0 + "###); + + // Requesting a satisfied requirement should use the base environment. + requirements_txt.write_str("sniffio")?; + + uv_snapshot!(context.filters(), context.run().arg("--with-requirements").arg(requirements_txt.as_os_str()).arg("main.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv run` is experimental and may change without warning + Resolved 6 packages in [TIME] + Audited 4 packages in [TIME] + "###); + + // Unless the user requests a different version. + requirements_txt.write_str("sniffio<1.3.1")?; + + uv_snapshot!(context.filters(), context.run().arg("--with-requirements").arg(requirements_txt.as_os_str()).arg("main.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv run` is experimental and may change without warning + 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] + + sniffio==1.3.0 + "###); + + // Or includes an unsatisfied requirement via `--with`. + requirements_txt.write_str("sniffio")?; + + uv_snapshot!(context.filters(), context.run() + .arg("--with-requirements") + .arg(requirements_txt.as_os_str()) + .arg("--with") + .arg("iniconfig") + .arg("main.py"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: `uv run` is experimental and may change without warning + Resolved 6 packages in [TIME] + Audited 4 packages in [TIME] + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 2 packages in [TIME] + + iniconfig==2.0.0 + + sniffio==1.3.1 + "###); + + // But reject `-` as a requirements file. + uv_snapshot!(context.filters(), context.run() + .arg("--with-requirements") + .arg("-") + .arg("--with") + .arg("iniconfig") + .arg("main.py"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: `uv run` is experimental and may change without warning + error: Reading requirements from stdin is not supported in `uv run` + "###); + + Ok(()) +}