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:
Zanie Blue 2024-07-23 12:51:09 -04:00 committed by GitHub
parent 49ea4a7a91
commit 5f1f9c8293
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 258 additions and 39 deletions

View file

@ -1847,6 +1847,12 @@ pub struct RunArgs {
#[arg(long)]
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.
#[arg(long, conflicts_with = "frozen")]
pub locked: bool,

View file

@ -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()
}
}

View file

@ -239,7 +239,7 @@ pub(crate) async fn add(
&VirtualProject::Project(project),
&venv,
&lock,
extras,
&extras,
dev,
Modifications::Sufficient,
settings.as_ref().into(),

View file

@ -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 {

View file

@ -132,7 +132,7 @@ pub(crate) async fn remove(
&VirtualProject::Project(project),
&venv,
&lock,
extras,
&extras,
dev,
Modifications::Exact,
settings.as_ref().into(),

View file

@ -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,19 +387,11 @@ pub(crate) async fn run(
false,
)?;
if requirements.is_empty() {
Some(venv)
} else {
match spec {
None => Some(venv),
Some(spec) if spec.is_empty() => Some(venv),
Some(spec) => {
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(
@ -394,6 +409,7 @@ pub(crate) async fn run(
.await?,
)
}
}
};
debug!("Running `{command}`");

View file

@ -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())

View file

@ -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::<Vec<_>>();
commands::run(

View file

@ -187,6 +187,7 @@ pub(crate) struct RunSettings {
pub(crate) dev: bool,
pub(crate) command: ExternalCommand,
pub(crate) with: Vec<String>,
pub(crate) with_requirements: Vec<PathBuf>,
pub(crate) package: Option<PackageName>,
pub(crate) python: Option<String>,
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),

View file

@ -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(())
}