mirror of
				https://github.com/astral-sh/uv.git
				synced 2025-10-30 19:48:11 +00:00 
			
		
		
		
	Add support for requirements files in uv run (#4973)
				
					
				
			Closes https://github.com/astral-sh/uv/issues/4824. --------- Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
		
							parent
							
								
									49ea4a7a91
								
							
						
					
					
						commit
						5f1f9c8293
					
				
					 10 changed files with 258 additions and 39 deletions
				
			
		|  | @ -1847,6 +1847,12 @@ pub struct RunArgs { | ||||||
|     #[arg(long)] |     #[arg(long)] | ||||||
|     pub with: Vec<String>, |     pub with: Vec<String>, | ||||||
| 
 | 
 | ||||||
|  |     /// 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<Maybe<PathBuf>>, | ||||||
|  | 
 | ||||||
|     /// Assert that the `uv.lock` will remain unchanged.
 |     /// Assert that the `uv.lock` will remain unchanged.
 | ||||||
|     #[arg(long, conflicts_with = "frozen")] |     #[arg(long, conflicts_with = "frozen")] | ||||||
|     pub locked: bool, |     pub locked: bool, | ||||||
|  |  | ||||||
|  | @ -336,4 +336,9 @@ impl RequirementsSpecification { | ||||||
|             ..Self::default() |             ..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() | ||||||
|  |     } | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -239,7 +239,7 @@ pub(crate) async fn add( | ||||||
|         &VirtualProject::Project(project), |         &VirtualProject::Project(project), | ||||||
|         &venv, |         &venv, | ||||||
|         &lock, |         &lock, | ||||||
|         extras, |         &extras, | ||||||
|         dev, |         dev, | ||||||
|         Modifications::Sufficient, |         Modifications::Sufficient, | ||||||
|         settings.as_ref().into(), |         settings.as_ref().into(), | ||||||
|  |  | ||||||
|  | @ -645,7 +645,11 @@ pub(crate) async fn update_environment( | ||||||
| 
 | 
 | ||||||
|     // Check if the current environment satisfies the requirements
 |     // Check if the current environment satisfies the requirements
 | ||||||
|     let site_packages = SitePackages::from_environment(&venv)?; |     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)? { |         match site_packages.satisfies(&spec.requirements, &spec.constraints)? { | ||||||
|             // If the requirements are already satisfied, we're done.
 |             // If the requirements are already satisfied, we're done.
 | ||||||
|             SatisfiesResult::Fresh { |             SatisfiesResult::Fresh { | ||||||
|  |  | ||||||
|  | @ -132,7 +132,7 @@ pub(crate) async fn remove( | ||||||
|         &VirtualProject::Project(project), |         &VirtualProject::Project(project), | ||||||
|         &venv, |         &venv, | ||||||
|         &lock, |         &lock, | ||||||
|         extras, |         &extras, | ||||||
|         dev, |         dev, | ||||||
|         Modifications::Exact, |         Modifications::Exact, | ||||||
|         settings.as_ref().into(), |         settings.as_ref().into(), | ||||||
|  |  | ||||||
|  | @ -1,9 +1,9 @@ | ||||||
| use std::borrow::Cow; | use std::borrow::Cow; | ||||||
| use std::ffi::OsString; | use std::ffi::OsString; | ||||||
| use std::fmt::Write; | 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 itertools::Itertools; | ||||||
| use owo_colors::OwoColorize; | use owo_colors::OwoColorize; | ||||||
| use tokio::process::Command; | 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"); |         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.
 |     // Parse the input command.
 | ||||||
|     let command = RunCommand::from(command); |     let command = RunCommand::from(command); | ||||||
| 
 | 
 | ||||||
|  | @ -216,7 +238,7 @@ pub(crate) async fn run( | ||||||
|                 &project, |                 &project, | ||||||
|                 &venv, |                 &venv, | ||||||
|                 &lock, |                 &lock, | ||||||
|                 extras, |                 &extras, | ||||||
|                 dev, |                 dev, | ||||||
|                 Modifications::Sufficient, |                 Modifications::Sufficient, | ||||||
|                 settings.as_ref().into(), |                 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() { |     let spec = if requirements.is_empty() { | ||||||
|         None |         None | ||||||
|     } else { |     } 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
 |     // any `--with` requirements, and we already have a base environment, then there's no need to
 | ||||||
|     // create an additional environment.
 |     // create an additional environment.
 | ||||||
|     let skip_ephemeral = base_interpreter.as_ref().is_some_and(|base_interpreter| { |     let skip_ephemeral = base_interpreter.as_ref().is_some_and(|base_interpreter| { | ||||||
|  |         // No additional requirements.
 | ||||||
|         let Some(spec) = spec.as_ref() else { |         let Some(spec) = spec.as_ref() else { | ||||||
|             return true; |             return true; | ||||||
|         }; |         }; | ||||||
|  | @ -364,19 +387,11 @@ pub(crate) async fn run( | ||||||
|             false, |             false, | ||||||
|         )?; |         )?; | ||||||
| 
 | 
 | ||||||
|         if requirements.is_empty() { |         match spec { | ||||||
|             Some(venv) |             None => Some(venv), | ||||||
|         } else { |             Some(spec) if spec.is_empty() => Some(venv), | ||||||
|  |             Some(spec) => { | ||||||
|                 debug!("Syncing ephemeral requirements"); |                 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.
 |                 // Install the ephemeral requirements.
 | ||||||
|                 Some( |                 Some( | ||||||
|                     project::update_environment( |                     project::update_environment( | ||||||
|  | @ -394,6 +409,7 @@ pub(crate) async fn run( | ||||||
|                     .await?, |                     .await?, | ||||||
|                 ) |                 ) | ||||||
|             } |             } | ||||||
|  |         } | ||||||
|     }; |     }; | ||||||
| 
 | 
 | ||||||
|     debug!("Running `{command}`"); |     debug!("Running `{command}`"); | ||||||
|  |  | ||||||
|  | @ -96,7 +96,7 @@ pub(crate) async fn sync( | ||||||
|         &project, |         &project, | ||||||
|         &venv, |         &venv, | ||||||
|         &lock, |         &lock, | ||||||
|         extras, |         &extras, | ||||||
|         dev, |         dev, | ||||||
|         modifications, |         modifications, | ||||||
|         settings.as_ref().into(), |         settings.as_ref().into(), | ||||||
|  | @ -118,7 +118,7 @@ pub(super) async fn do_sync( | ||||||
|     project: &VirtualProject, |     project: &VirtualProject, | ||||||
|     venv: &PythonEnvironment, |     venv: &PythonEnvironment, | ||||||
|     lock: &Lock, |     lock: &Lock, | ||||||
|     extras: ExtrasSpecification, |     extras: &ExtrasSpecification, | ||||||
|     dev: bool, |     dev: bool, | ||||||
|     modifications: Modifications, |     modifications: Modifications, | ||||||
|     settings: InstallerSettingsRef<'_>, |     settings: InstallerSettingsRef<'_>, | ||||||
|  | @ -164,7 +164,7 @@ pub(super) async fn do_sync( | ||||||
|     let tags = venv.interpreter().tags()?; |     let tags = venv.interpreter().tags()?; | ||||||
| 
 | 
 | ||||||
|     // Read the lockfile.
 |     // 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.
 |     // Initialize the registry client.
 | ||||||
|     let client = RegistryClientBuilder::new(cache.clone()) |     let client = RegistryClientBuilder::new(cache.clone()) | ||||||
|  |  | ||||||
|  | @ -859,6 +859,11 @@ async fn run_project( | ||||||
|                 .with |                 .with | ||||||
|                 .into_iter() |                 .into_iter() | ||||||
|                 .map(RequirementsSource::from_package) |                 .map(RequirementsSource::from_package) | ||||||
|  |                 .chain( | ||||||
|  |                     args.with_requirements | ||||||
|  |                         .into_iter() | ||||||
|  |                         .map(RequirementsSource::from_requirements_file), | ||||||
|  |                 ) | ||||||
|                 .collect::<Vec<_>>(); |                 .collect::<Vec<_>>(); | ||||||
| 
 | 
 | ||||||
|             commands::run( |             commands::run( | ||||||
|  |  | ||||||
|  | @ -187,6 +187,7 @@ pub(crate) struct RunSettings { | ||||||
|     pub(crate) dev: bool, |     pub(crate) dev: bool, | ||||||
|     pub(crate) command: ExternalCommand, |     pub(crate) command: ExternalCommand, | ||||||
|     pub(crate) with: Vec<String>, |     pub(crate) with: Vec<String>, | ||||||
|  |     pub(crate) with_requirements: Vec<PathBuf>, | ||||||
|     pub(crate) package: Option<PackageName>, |     pub(crate) package: Option<PackageName>, | ||||||
|     pub(crate) python: Option<String>, |     pub(crate) python: Option<String>, | ||||||
|     pub(crate) refresh: Refresh, |     pub(crate) refresh: Refresh, | ||||||
|  | @ -207,6 +208,7 @@ impl RunSettings { | ||||||
|             no_dev, |             no_dev, | ||||||
|             command, |             command, | ||||||
|             with, |             with, | ||||||
|  |             with_requirements, | ||||||
|             installer, |             installer, | ||||||
|             build, |             build, | ||||||
|             refresh, |             refresh, | ||||||
|  | @ -224,6 +226,10 @@ impl RunSettings { | ||||||
|             dev: flag(dev, no_dev).unwrap_or(true), |             dev: flag(dev, no_dev).unwrap_or(true), | ||||||
|             command, |             command, | ||||||
|             with, |             with, | ||||||
|  |             with_requirements: with_requirements | ||||||
|  |                 .into_iter() | ||||||
|  |                 .filter_map(Maybe::into_option) | ||||||
|  |                 .collect(), | ||||||
|             package, |             package, | ||||||
|             python, |             python, | ||||||
|             refresh: Refresh::from(refresh), |             refresh: Refresh::from(refresh), | ||||||
|  |  | ||||||
|  | @ -2,7 +2,7 @@ | ||||||
| 
 | 
 | ||||||
| use anyhow::Result; | use anyhow::Result; | ||||||
| use assert_cmd::assert::OutputAssertExt; | use assert_cmd::assert::OutputAssertExt; | ||||||
| use assert_fs::prelude::*; | use assert_fs::{fixture::ChildPath, prelude::*}; | ||||||
| use indoc::indoc; | use indoc::indoc; | ||||||
| 
 | 
 | ||||||
| use common::{uv_snapshot, TestContext}; | use common::{uv_snapshot, TestContext}; | ||||||
|  | @ -541,3 +541,180 @@ fn run_frozen() -> Result<()> { | ||||||
| 
 | 
 | ||||||
|     Ok(()) |     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(()) | ||||||
|  | } | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Zanie Blue
						Zanie Blue