mirror of
https://github.com/astral-sh/uv.git
synced 2025-10-29 11:07:59 +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)]
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ pub(crate) async fn add(
|
|||
&VirtualProject::Project(project),
|
||||
&venv,
|
||||
&lock,
|
||||
extras,
|
||||
&extras,
|
||||
dev,
|
||||
Modifications::Sufficient,
|
||||
settings.as_ref().into(),
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ pub(crate) async fn remove(
|
|||
&VirtualProject::Project(project),
|
||||
&venv,
|
||||
&lock,
|
||||
extras,
|
||||
&extras,
|
||||
dev,
|
||||
Modifications::Exact,
|
||||
settings.as_ref().into(),
|
||||
|
|
|
|||
|
|
@ -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}`");
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue