diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index c687b0bd8ac8..13f96bf93402 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -1781,6 +1781,37 @@ pub struct RunArgs { /// - `/home/ferris/.local/bin/python3.10` uses the exact Python at the given path. #[arg(long, short, env = "UV_PYTHON", verbatim_doc_comment)] pub python: Option, + + /// Require all packages listed in the given `requirements.txt` files. + /// + /// If a `pyproject.toml`, `setup.py`, or `setup.cfg` file is provided, `uv` will + /// extract the requirements for the relevant project. + /// + /// If `-` is provided, then requirements will be read from stdin. + #[arg(long, short, group = "sources", value_parser = parse_file_path)] + pub requirement: Vec, + + /// Constrain versions using the given requirements files. + /// + /// Constraints files are `requirements.txt`-like files that only control the _version_ of a + /// requirement that's installed. However, including a package in a constraints file will _not_ + /// trigger the installation of that package. + /// + /// This is equivalent to pip's `--constraint` option. + #[arg(long, short, env = "UV_CONSTRAINT", value_delimiter = ' ', value_parser = parse_maybe_file_path)] + pub constraint: Vec>, + + /// Override versions using the given requirements files. + /// + /// Overrides files are `requirements.txt`-like files that force a specific version of a + /// requirement to be installed, regardless of the requirements declared by any constituent + /// package, and regardless of whether this would be considered an invalid resolution. + /// + /// While constraints are _additive_, in that they're combined with the requirements of the + /// constituent packages, overrides are _absolute_, in that they completely replace the + /// requirements of the constituent packages. + #[arg(long, env = "UV_OVERRIDE", value_delimiter = ' ', value_parser = parse_maybe_file_path)] + pub r#override: Vec>, } #[derive(Args)] diff --git a/crates/uv-requirements/src/specification.rs b/crates/uv-requirements/src/specification.rs index 1544714260d5..ac0c10c9174b 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 2c8e1e6ac4cf..edc8c53c339e 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -237,7 +237,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/remove.rs b/crates/uv/src/commands/project/remove.rs index 82be0cfad277..8d485f673b83 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -130,7 +130,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 48148349e83c..362b389b0ff5 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -3,7 +3,7 @@ use std::ffi::OsString; use std::fmt::Write; use std::path::PathBuf; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use itertools::Itertools; use owo_colors::OwoColorize; use tokio::process::Command; @@ -23,10 +23,10 @@ use uv_python::{ PythonInstallation, PythonPreference, PythonRequest, VersionRequest, }; -use uv_requirements::{RequirementsSource, RequirementsSpecification}; +use uv_requirements::RequirementsSource; use uv_warnings::warn_user_once; -use crate::commands::pip::operations::Modifications; +use crate::commands::pip::operations::{self, Modifications}; use crate::commands::project::environment::CachedEnvironment; use crate::commands::reporters::PythonDownloadReporter; use crate::commands::{project, ExitStatus, SharedState}; @@ -37,6 +37,8 @@ use crate::settings::ResolverInstallerSettings; pub(crate) async fn run( command: ExternalCommand, requirements: Vec, + constraints: &[RequirementsSource], + overrides: &[RequirementsSource], package: Option, extras: ExtrasSpecification, dev: bool, @@ -56,6 +58,27 @@ pub(crate) async fn run( warn_user_once!("`uv run` is experimental and may change without warning."); } + // These cases seem quite complex because it changes the current package — let's just + // ban it entirely for now + if requirements + .iter() + .any(|source| matches!(source, RequirementsSource::PyprojectToml(_))) + { + bail!("Adding requirements from a `pyproject.toml` is not supported in `uv run`"); + } + if requirements + .iter() + .any(|source| matches!(source, RequirementsSource::SetupCfg(_))) + { + bail!("Adding requirements from a `setup.cfg` is not supported in `uv run`"); + } + if requirements + .iter() + .any(|source| matches!(source, RequirementsSource::SetupCfg(_))) + { + bail!("Adding requirements from a `setup.py` is not supported in `uv run`"); + } + // Parse the input command. let command = RunCommand::from(command); @@ -207,7 +230,7 @@ pub(crate) async fn run( &project, &venv, &lock, - extras, + &extras, dev, Modifications::Sufficient, settings.as_ref().into(), @@ -255,16 +278,22 @@ pub(crate) async fn run( ); } - // Read the `--with` requirements. - let spec = if requirements.is_empty() { + // Read the requirements. + let spec = if requirements.is_empty() && constraints.is_empty() && overrides.is_empty() { None } else { let client_builder = BaseClientBuilder::new() .connectivity(connectivity) .native_tls(native_tls); - let spec = - RequirementsSpecification::from_simple_sources(&requirements, &client_builder).await?; + let spec = operations::read_requirements( + &requirements, + constraints, + overrides, + &extras, + &client_builder, + ) + .await?; Some(spec) }; @@ -285,6 +314,7 @@ pub(crate) async fn run( return false; } + match site_packages.satisfies(&spec.requirements, &spec.constraints, &spec.overrides) { // If the requirements are already satisfied, we're done. Ok(SatisfiesResult::Fresh { @@ -355,35 +385,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 8b2288a07647..8096942d5a80 100644 --- a/crates/uv/src/commands/project/sync.rs +++ b/crates/uv/src/commands/project/sync.rs @@ -147,7 +147,7 @@ pub(crate) async fn sync( &project, &venv, &lock, - extras, + &extras, dev, modifications, settings.as_ref().into(), @@ -169,7 +169,7 @@ pub(super) async fn do_sync( project: &VirtualProject, venv: &PythonEnvironment, lock: &Lock, - extras: ExtrasSpecification, + extras: &ExtrasSpecification, dev: bool, modifications: Modifications, settings: InstallerSettingsRef<'_>, @@ -215,7 +215,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 cd00d67e4431..e40dee2f61e6 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -813,11 +813,28 @@ async fn run_project( .with .into_iter() .map(RequirementsSource::from_package) + .chain( + args.requirement + .into_iter() + .map(RequirementsSource::from_requirements_file), + ) + .collect::>(); + let constraints = args + .constraint + .into_iter() + .map(RequirementsSource::from_constraints_txt) + .collect::>(); + let overrides = args + .r#override + .into_iter() + .map(RequirementsSource::from_overrides_txt) .collect::>(); commands::run( args.command, requirements, + &constraints, + &overrides, args.package, args.extras, args.dev, diff --git a/crates/uv/src/settings.rs b/crates/uv/src/settings.rs index c4a8663ade98..b08f1d6272f2 100644 --- a/crates/uv/src/settings.rs +++ b/crates/uv/src/settings.rs @@ -155,6 +155,9 @@ pub(crate) struct RunSettings { pub(crate) with: Vec, pub(crate) package: Option, pub(crate) python: Option, + pub(crate) requirement: Vec, + pub(crate) constraint: Vec, + pub(crate) r#override: Vec, pub(crate) refresh: Refresh, pub(crate) settings: ResolverInstallerSettings, } @@ -176,6 +179,9 @@ impl RunSettings { refresh, package, python, + requirement, + constraint, + r#override, } = args; Self { @@ -189,6 +195,15 @@ impl RunSettings { package, python, refresh: Refresh::from(refresh), + requirement, + constraint: constraint + .into_iter() + .filter_map(Maybe::into_option) + .collect(), + r#override: r#override + .into_iter() + .filter_map(Maybe::into_option) + .collect(), settings: ResolverInstallerSettings::combine( resolver_installer_options(installer, build), filesystem, diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index 3314257cfe1c..01287efbd053 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -1,7 +1,7 @@ #![cfg(all(feature = "python", feature = "pypi"))] use anyhow::Result; -use assert_fs::prelude::*; +use assert_fs::{fixture::ChildPath, prelude::*}; use indoc::indoc; use common::{uv_snapshot, TestContext}; @@ -400,3 +400,345 @@ fn run_with() -> 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("-r").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("-r").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("-r").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("-r").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("-r").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("-r") + .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 + "###); + + Ok(()) +} + +#[test] +fn run_constraints_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 + " + })?; + + // Constraining an unrequested requirement should not include it. + let constraints_txt = context.temp_dir.child("constraints.txt"); + constraints_txt.write_str("iniconfig")?; + + uv_snapshot!(context.filters(), context.run().arg("--constraint").arg(constraints_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 + "###); + + // Subsequent invocations should use the base environment if there's a compatible constraint. + constraints_txt.write_str("sniffio")?; + + uv_snapshot!(context.filters(), context.run().arg("--constraint").arg(constraints_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 of a base requirement. + constraints_txt.write_str("sniffio<1.3.1")?; + + uv_snapshot!(context.filters(), context.run().arg("--constraint").arg(constraints_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] + "###); + + // Or includes an unsatisfied requirement via `--with`. + constraints_txt.write_str("iniconfig<2.0.0")?; + + uv_snapshot!(context.filters(), context.run() + .arg("--constraint") + .arg(constraints_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 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "###); + + Ok(()) +} + +#[test] +fn run_overrides_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 + " + })?; + + // Overriding an unrequested requirement should not include it. + let overrides_txt = context.temp_dir.child("overrides.txt"); + overrides_txt.write_str("iniconfig==2.0.0")?; + + uv_snapshot!(context.filters(), context.run().arg("--override").arg(overrides_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 + "###); + + // Subsequent invocations should use the base environment if there is a compatible override. + overrides_txt.write_str("idna==3.6")?; + + uv_snapshot!(context.filters(), context.run().arg("--override").arg(overrides_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] + "###); + + // Overriding a dependency should result in an ephemeral environment. + // TODO(zanieb): However, it does not result in an ephemeral environment without `--with` + // because `SitePackages::satisfies` only checks if overrides are satisfied if + // a parent requirement is requested and the parent requirement (in this case) + // comes from the base environment. We'd need to read include the project's base + // requirements in the `satisfies` check to force a new environment here. + overrides_txt.write_str("idna==3.5")?; + + uv_snapshot!(context.filters(), context.run() + .arg("--override") + .arg(overrides_txt.as_os_str()) + .arg("--with") + .arg("anyio") + .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 3 packages in [TIME] + Prepared 3 packages in [TIME] + Installed 3 packages in [TIME] + + anyio==4.3.0 + + idna==3.5 + + sniffio==1.3.1 + "###); + + Ok(()) +}