mirror of
https://github.com/astral-sh/uv.git
synced 2025-10-31 20:09:09 +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,35 +387,28 @@ 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),
|
||||||
debug!("Syncing ephemeral requirements");
|
Some(spec) => {
|
||||||
|
debug!("Syncing ephemeral requirements");
|
||||||
let client_builder = BaseClientBuilder::new()
|
// Install the ephemeral requirements.
|
||||||
.connectivity(connectivity)
|
Some(
|
||||||
.native_tls(native_tls);
|
project::update_environment(
|
||||||
|
venv,
|
||||||
let spec =
|
spec,
|
||||||
RequirementsSpecification::from_simple_sources(&requirements, &client_builder)
|
&settings,
|
||||||
.await?;
|
&state,
|
||||||
|
preview,
|
||||||
// Install the ephemeral requirements.
|
connectivity,
|
||||||
Some(
|
concurrency,
|
||||||
project::update_environment(
|
native_tls,
|
||||||
venv,
|
cache,
|
||||||
spec,
|
printer,
|
||||||
&settings,
|
)
|
||||||
&state,
|
.await?,
|
||||||
preview,
|
|
||||||
connectivity,
|
|
||||||
concurrency,
|
|
||||||
native_tls,
|
|
||||||
cache,
|
|
||||||
printer,
|
|
||||||
)
|
)
|
||||||
.await?,
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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