From 6eefde28e7f0795bb83b0375e415212a2c9b5556 Mon Sep 17 00:00:00 2001
From: Ahmed Ilyas
Date: Fri, 5 Sep 2025 18:45:46 +0200
Subject: [PATCH] Support `--with-requirements script.py` and `-r script.py` to
include inline dependency metadata from another script (#12763)
## Summary
Closes #6542
## Test Plan
`cargo test`
---
Cargo.lock | 1 +
crates/uv-cli/src/lib.rs | 11 +-
crates/uv-requirements/Cargo.toml | 1 +
crates/uv-requirements/src/sources.rs | 9 ++
crates/uv-requirements/src/specification.rs | 122 ++++++++++++++++++-
crates/uv/src/commands/project/add.rs | 3 +
crates/uv/tests/it/pip_install.rs | 65 ++++++++++
crates/uv/tests/it/tool_install.rs | 125 ++++++++++++++++++++
crates/uv/tests/it/tool_run.rs | 74 ++++++++++++
docs/reference/cli.md | 8 +-
10 files changed, 410 insertions(+), 9 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 1e0385e98..e268a416c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6189,6 +6189,7 @@ dependencies = [
"uv-redacted",
"uv-requirements-txt",
"uv-resolver",
+ "uv-scripts",
"uv-types",
"uv-warnings",
"uv-workspace",
diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs
index 358634477..f0c81fdc4 100644
--- a/crates/uv-cli/src/lib.rs
+++ b/crates/uv-cli/src/lib.rs
@@ -1836,7 +1836,7 @@ pub struct PipInstallArgs {
#[arg(group = "sources")]
pub package: Vec,
- /// Install all packages listed in the given `requirements.txt` or `pylock.toml` files.
+ /// Install all packages listed in the given `requirements.txt`, PEP 723 scripts, or `pylock.toml` files.
///
/// If a `pyproject.toml`, `setup.py`, or `setup.cfg` file is provided, uv will extract the
/// requirements for the relevant project.
@@ -3205,7 +3205,8 @@ pub struct RunArgs {
#[arg(long)]
pub with_editable: Vec,
- /// Run with all packages listed in the given `requirements.txt` files.
+ /// Run with all packages listed in the given `requirements.txt` files or PEP 723 Python
+ /// scripts.
///
/// The same environment semantics as `--with` apply.
///
@@ -4575,7 +4576,8 @@ pub struct ToolRunArgs {
#[arg(long)]
pub with_editable: Vec,
- /// Run with all packages listed in the given `requirements.txt` files.
+ /// Run with all packages listed in the given `requirements.txt` files or PEP 723 Python
+ /// scripts.
#[arg(long, value_delimiter = ',', value_parser = parse_maybe_file_path)]
pub with_requirements: Vec>,
@@ -4705,7 +4707,8 @@ pub struct ToolInstallArgs {
#[arg(short = 'w', long)]
pub with: Vec,
- /// Include all requirements listed in the given `requirements.txt` files.
+ /// Run with all packages listed in the given `requirements.txt` files or PEP 723 Python
+ /// scripts.
#[arg(long, value_delimiter = ',', value_parser = parse_maybe_file_path)]
pub with_requirements: Vec>,
diff --git a/crates/uv-requirements/Cargo.toml b/crates/uv-requirements/Cargo.toml
index ef372032d..af1092f8b 100644
--- a/crates/uv-requirements/Cargo.toml
+++ b/crates/uv-requirements/Cargo.toml
@@ -31,6 +31,7 @@ uv-pypi-types = { workspace = true }
uv-redacted = { workspace = true }
uv-requirements-txt = { workspace = true, features = ["http"] }
uv-resolver = { workspace = true, features = ["clap"] }
+uv-scripts = { workspace = true }
uv-types = { workspace = true }
uv-warnings = { workspace = true }
uv-workspace = { workspace = true }
diff --git a/crates/uv-requirements/src/sources.rs b/crates/uv-requirements/src/sources.rs
index 024ac5ebf..8bba3213c 100644
--- a/crates/uv-requirements/src/sources.rs
+++ b/crates/uv-requirements/src/sources.rs
@@ -13,6 +13,8 @@ pub enum RequirementsSource {
Package(RequirementsTxtRequirement),
/// An editable path was provided on the command line (e.g., `pip install -e ../flask`).
Editable(RequirementsTxtRequirement),
+ /// Dependencies were provided via a PEP 723 script.
+ Pep723Script(PathBuf),
/// Dependencies were provided via a `pylock.toml` file.
PylockToml(PathBuf),
/// Dependencies were provided via a `requirements.txt` file (e.g., `pip install -r requirements.txt`).
@@ -44,6 +46,12 @@ impl RequirementsSource {
.is_some_and(|file_name| file_name.to_str().is_some_and(is_pylock_toml))
{
Ok(Self::PylockToml(path))
+ } else if path
+ .extension()
+ .is_some_and(|ext| ext.eq_ignore_ascii_case("py") || ext.eq_ignore_ascii_case("pyw"))
+ {
+ // TODO(blueraft): Support scripts without an extension.
+ Ok(Self::Pep723Script(path))
} else if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("toml"))
@@ -290,6 +298,7 @@ impl std::fmt::Display for RequirementsSource {
Self::Editable(path) => write!(f, "-e {path:?}"),
Self::PylockToml(path)
| Self::RequirementsTxt(path)
+ | Self::Pep723Script(path)
| Self::PyprojectToml(path)
| Self::SetupPy(path)
| Self::SetupCfg(path)
diff --git a/crates/uv-requirements/src/specification.rs b/crates/uv-requirements/src/specification.rs
index 88a5eba21..bc21b3bf0 100644
--- a/crates/uv-requirements/src/specification.rs
+++ b/crates/uv-requirements/src/specification.rs
@@ -37,7 +37,7 @@ use tracing::instrument;
use uv_cache_key::CanonicalUrl;
use uv_client::BaseClientBuilder;
use uv_configuration::{DependencyGroups, NoBinary, NoBuild};
-use uv_distribution_types::Requirement;
+use uv_distribution_types::{Index, Requirement};
use uv_distribution_types::{
IndexUrl, NameRequirementSpecification, UnresolvedRequirement,
UnresolvedRequirementSpecification,
@@ -45,6 +45,7 @@ use uv_distribution_types::{
use uv_fs::{CWD, Simplified};
use uv_normalize::{ExtraName, PackageName, PipGroupName};
use uv_requirements_txt::{RequirementsTxt, RequirementsTxtRequirement};
+use uv_scripts::{Pep723Error, Pep723Item, Pep723Script};
use uv_warnings::warn_user;
use uv_workspace::pyproject::PyProjectToml;
@@ -181,6 +182,125 @@ impl RequirementsSpecification {
..Self::default()
}
}
+ RequirementsSource::Pep723Script(path) => {
+ let script = match Pep723Script::read(&path).await {
+ Ok(Some(script)) => Pep723Item::Script(script),
+ Ok(None) => {
+ return Err(anyhow::anyhow!(
+ "`{}` does not contain inline script metadata",
+ path.user_display(),
+ ));
+ }
+ Err(Pep723Error::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
+ return Err(anyhow::anyhow!(
+ "Failed to read `{}` (not found)",
+ path.user_display(),
+ ));
+ }
+ Err(err) => return Err(err.into()),
+ };
+
+ let metadata = script.metadata();
+
+ let requirements = metadata
+ .dependencies
+ .as_ref()
+ .map(|dependencies| {
+ dependencies
+ .iter()
+ .map(|dependency| {
+ UnresolvedRequirementSpecification::from(Requirement::from(
+ dependency.to_owned(),
+ ))
+ })
+ .collect::>()
+ })
+ .unwrap_or_default();
+
+ if let Some(tool_uv) = metadata.tool.as_ref().and_then(|tool| tool.uv.as_ref()) {
+ let constraints = tool_uv
+ .constraint_dependencies
+ .as_ref()
+ .map(|dependencies| {
+ dependencies
+ .iter()
+ .map(|dependency| {
+ NameRequirementSpecification::from(Requirement::from(
+ dependency.to_owned(),
+ ))
+ })
+ .collect::>()
+ })
+ .unwrap_or_default();
+
+ let overrides = tool_uv
+ .override_dependencies
+ .as_ref()
+ .map(|dependencies| {
+ dependencies
+ .iter()
+ .map(|dependency| {
+ UnresolvedRequirementSpecification::from(Requirement::from(
+ dependency.to_owned(),
+ ))
+ })
+ .collect::>()
+ })
+ .unwrap_or_default();
+
+ Self {
+ requirements,
+ constraints,
+ overrides,
+ index_url: tool_uv
+ .top_level
+ .index_url
+ .as_ref()
+ .map(|index| Index::from(index.clone()).url),
+ extra_index_urls: tool_uv
+ .top_level
+ .extra_index_url
+ .as_ref()
+ .into_iter()
+ .flat_map(|urls| {
+ urls.iter().map(|index| Index::from(index.clone()).url)
+ })
+ .collect(),
+ no_index: tool_uv.top_level.no_index.unwrap_or_default(),
+ find_links: tool_uv
+ .top_level
+ .find_links
+ .as_ref()
+ .into_iter()
+ .flat_map(|urls| {
+ urls.iter().map(|index| Index::from(index.clone()).url)
+ })
+ .collect(),
+ no_binary: NoBinary::from_args(
+ tool_uv.top_level.no_binary,
+ tool_uv
+ .top_level
+ .no_binary_package
+ .clone()
+ .unwrap_or_default(),
+ ),
+ no_build: NoBuild::from_args(
+ tool_uv.top_level.no_build,
+ tool_uv
+ .top_level
+ .no_build_package
+ .clone()
+ .unwrap_or_default(),
+ ),
+ ..Self::default()
+ }
+ } else {
+ Self {
+ requirements,
+ ..Self::default()
+ }
+ }
+ }
RequirementsSource::SetupPy(path) | RequirementsSource::SetupCfg(path) => {
if !path.is_file() {
return Err(anyhow::anyhow!("File not found: `{}`", path.user_display()));
diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs
index 42a0ee707..c3443817b 100644
--- a/crates/uv/src/commands/project/add.rs
+++ b/crates/uv/src/commands/project/add.rs
@@ -125,6 +125,9 @@ pub(crate) async fn add(
RequirementsSource::SetupPy(_) => {
bail!("Adding requirements from a `setup.py` is not supported in `uv add`");
}
+ RequirementsSource::Pep723Script(_) => {
+ bail!("Adding requirements from a PEP 723 script is not supported in `uv add`");
+ }
RequirementsSource::SetupCfg(_) => {
bail!("Adding requirements from a `setup.cfg` is not supported in `uv add`");
}
diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs
index ab8added9..92ddd2a80 100644
--- a/crates/uv/tests/it/pip_install.rs
+++ b/crates/uv/tests/it/pip_install.rs
@@ -757,6 +757,71 @@ werkzeug==3.0.1
Ok(())
}
+#[test]
+fn install_with_dependencies_from_script() -> Result<()> {
+ let context = TestContext::new("3.12");
+ let script = context.temp_dir.child("script.py");
+ script.write_str(indoc! {r#"
+ # /// script
+ # requires-python = ">=3.11"
+ # dependencies = [
+ # "anyio",
+ # ]
+ # ///
+
+ import anyio
+ "#})?;
+
+ uv_snapshot!(context.pip_install()
+ .arg("-r")
+ .arg("script.py")
+ .arg("--strict"), @r"
+ success: true
+ exit_code: 0
+ ----- stdout -----
+
+ ----- stderr -----
+ Resolved 3 packages in [TIME]
+ Prepared 3 packages in [TIME]
+ Installed 3 packages in [TIME]
+ + anyio==4.3.0
+ + idna==3.6
+ + sniffio==1.3.1
+ "
+ );
+
+ // Update the script file.
+ script.write_str(indoc! {r#"
+ # /// script
+ # requires-python = ">=3.11"
+ # dependencies = [
+ # "anyio",
+ # "iniconfig",
+ # ]
+ # ///
+
+ import anyio
+ "#})?;
+
+ uv_snapshot!(context.pip_install()
+ .arg("-r")
+ .arg("script.py")
+ .arg("--strict"), @r"
+ success: true
+ exit_code: 0
+ ----- stdout -----
+
+ ----- stderr -----
+ Resolved 4 packages in [TIME]
+ Prepared 1 package in [TIME]
+ Installed 1 package in [TIME]
+ + iniconfig==2.0.0
+ "
+ );
+
+ Ok(())
+}
+
/// Install a `pyproject.toml` file with a `poetry` section.
#[test]
fn install_pyproject_toml_poetry() -> Result<()> {
diff --git a/crates/uv/tests/it/tool_install.rs b/crates/uv/tests/it/tool_install.rs
index 793d1f6be..bb7f45b32 100644
--- a/crates/uv/tests/it/tool_install.rs
+++ b/crates/uv/tests/it/tool_install.rs
@@ -2026,6 +2026,131 @@ fn tool_install_unnamed_with() {
"###);
}
+#[test]
+fn tool_install_with_dependencies_from_script() -> Result<()> {
+ let context = TestContext::new("3.12")
+ .with_filtered_counts()
+ .with_filtered_exe_suffix();
+ let tool_dir = context.temp_dir.child("tools");
+ let bin_dir = context.temp_dir.child("bin");
+
+ let script = context.temp_dir.child("script.py");
+ script.write_str(indoc! {r#"
+ # /// script
+ # requires-python = ">=3.11"
+ # dependencies = [
+ # "anyio",
+ # ]
+ # ///
+
+ import anyio
+ "#})?;
+
+ // script dependencies (anyio) are now installed.
+ uv_snapshot!(context.filters(), context.tool_install()
+ .arg("--with-requirements")
+ .arg("script.py")
+ .arg("black")
+ .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
+ .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
+ .env(EnvVars::PATH, bin_dir.as_os_str()), @r"
+ success: true
+ exit_code: 0
+ ----- stdout -----
+
+ ----- stderr -----
+ Resolved [N] packages in [TIME]
+ Prepared [N] packages in [TIME]
+ Installed [N] packages in [TIME]
+ + anyio==4.3.0
+ + black==24.3.0
+ + click==8.1.7
+ + idna==3.6
+ + mypy-extensions==1.0.0
+ + packaging==24.0
+ + pathspec==0.12.1
+ + platformdirs==4.2.0
+ + sniffio==1.3.1
+ Installed 2 executables: black, blackd
+ ");
+
+ insta::with_settings!({
+ filters => context.filters(),
+ }, {
+ // We should have a tool receipt
+ assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r#"
+ [tool]
+ requirements = [
+ { name = "black" },
+ { name = "anyio" },
+ ]
+ entrypoints = [
+ { name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
+ { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
+ ]
+
+ [tool.options]
+ exclude-newer = "2024-03-25T00:00:00Z"
+ "#);
+ });
+
+ // Update the script file.
+ script.write_str(indoc! {r#"
+ # /// script
+ # requires-python = ">=3.11"
+ # dependencies = [
+ # "anyio",
+ # "iniconfig",
+ # ]
+ # ///
+
+ import anyio
+ "#})?;
+
+ // Install `black`
+ uv_snapshot!(context.filters(), context.tool_install()
+ .arg("black")
+ .arg("--with-requirements")
+ .arg("script.py")
+ .env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
+ .env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
+ .env(EnvVars::PATH, bin_dir.as_os_str()), @r"
+ success: true
+ exit_code: 0
+ ----- stdout -----
+
+ ----- stderr -----
+ Resolved [N] packages in [TIME]
+ Prepared [N] packages in [TIME]
+ Installed [N] packages in [TIME]
+ + iniconfig==2.0.0
+ Installed 2 executables: black, blackd
+ ");
+
+ insta::with_settings!({
+ filters => context.filters(),
+ }, {
+ // We should have a tool receipt
+ assert_snapshot!(fs_err::read_to_string(tool_dir.join("black").join("uv-receipt.toml")).unwrap(), @r#"
+ [tool]
+ requirements = [
+ { name = "black" },
+ { name = "anyio" },
+ { name = "iniconfig" },
+ ]
+ entrypoints = [
+ { name = "black", install-path = "[TEMP_DIR]/bin/black", from = "black" },
+ { name = "blackd", install-path = "[TEMP_DIR]/bin/blackd", from = "black" },
+ ]
+
+ [tool.options]
+ exclude-newer = "2024-03-25T00:00:00Z"
+ "#);
+ });
+
+ Ok(())
+}
+
/// Test installing a tool with additional requirements from a `requirements.txt` file.
#[test]
fn tool_install_requirements_txt() {
diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs
index f91b757fb..1dd90e633 100644
--- a/crates/uv/tests/it/tool_run.rs
+++ b/crates/uv/tests/it/tool_run.rs
@@ -2658,6 +2658,80 @@ fn tool_run_with_incompatible_build_constraints() -> Result<()> {
Ok(())
}
+#[test]
+fn tool_run_with_dependencies_from_script() -> Result<()> {
+ let context = TestContext::new("3.12").with_filtered_counts();
+
+ let script = context.temp_dir.child("script.py");
+ script.write_str(indoc! {r#"
+ # /// script
+ # requires-python = ">=3.11"
+ # dependencies = [
+ # "anyio",
+ # ]
+ # ///
+
+ import anyio
+ "#})?;
+
+ // script dependencies (anyio) are now installed.
+ uv_snapshot!(context.filters(), context.tool_run()
+ .arg("--with-requirements")
+ .arg("script.py")
+ .arg("black")
+ .arg("script.py")
+ .arg("-q"), @r"
+ success: true
+ exit_code: 0
+ ----- stdout -----
+
+ ----- stderr -----
+ Resolved [N] packages in [TIME]
+ Prepared [N] packages in [TIME]
+ Installed [N] packages in [TIME]
+ + anyio==4.3.0
+ + black==24.3.0
+ + click==8.1.7
+ + idna==3.6
+ + mypy-extensions==1.0.0
+ + packaging==24.0
+ + pathspec==0.12.1
+ + platformdirs==4.2.0
+ + sniffio==1.3.1
+ ");
+
+ // Error when the script is not a valid PEP723 script.
+ let script = context.temp_dir.child("not_pep723_script.py");
+ script.write_str("import anyio")?;
+
+ uv_snapshot!(context.filters(), context.tool_run()
+ .arg("--with-requirements")
+ .arg("not_pep723_script.py")
+ .arg("black"), @r"
+ success: false
+ exit_code: 2
+ ----- stdout -----
+
+ ----- stderr -----
+ error: `not_pep723_script.py` does not contain inline script metadata
+ ");
+
+ // Error when the script doesn't exist.
+ uv_snapshot!(context.filters(), context.tool_run()
+ .arg("--with-requirements")
+ .arg("missing_file.py")
+ .arg("black"), @r"
+ success: false
+ exit_code: 2
+ ----- stdout -----
+
+ ----- stderr -----
+ error: Failed to read `missing_file.py` (not found)
+ ");
+
+ Ok(())
+}
+
/// Test windows runnable types, namely console scripts and legacy setuptools scripts.
/// Console Scripts
/// Legacy Scripts .
diff --git a/docs/reference/cli.md b/docs/reference/cli.md
index 5044de637..7e77a235c 100644
--- a/docs/reference/cli.md
+++ b/docs/reference/cli.md
@@ -611,7 +611,7 @@ used.
When used in a project, these dependencies will be layered on top of the project environment in a separate, ephemeral environment. These dependencies are allowed to conflict with those specified by the project.
--with-editable with-editableRun with the given packages installed in editable mode.
When used in a project, these dependencies will be layered on top of the project environment in a separate, ephemeral environment. These dependencies are allowed to conflict with those specified by the project.
---with-requirements with-requirementsRun with all packages listed in the given requirements.txt files.
+--with-requirements with-requirementsRun with all packages listed in the given requirements.txt files or PEP 723 Python scripts.
The same environment semantics as --with apply.
Using pyproject.toml, setup.py, or setup.cfg files is not allowed.
@@ -2546,7 +2546,7 @@ uv tool run [OPTIONS] [COMMAND]
--with, -w withRun with the given packages installed
--with-editable with-editableRun with the given packages installed in editable mode
When used in a project, these dependencies will be layered on top of the uv tool's environment in a separate, ephemeral environment. These dependencies are allowed to conflict with those specified.
---with-requirements with-requirementsRun with all packages listed in the given requirements.txt files
+--with-requirements with-requirementsRun with all packages listed in the given requirements.txt files or PEP 723 Python scripts
### uv tool install
@@ -2773,7 +2773,7 @@ uv tool install [OPTIONS]
--with, -w withInclude the following additional requirements
--with-editable with-editableInclude the given packages in editable mode
--with-executables-from with-executables-fromInstall executables from the following packages
---with-requirements with-requirementsInclude all requirements listed in the given requirements.txt files
+--with-requirements with-requirementsRun with all packages listed in the given requirements.txt files or PEP 723 Python scripts
### uv tool upgrade
@@ -4808,7 +4808,7 @@ should be used with caution, as it can modify the system Python installation.
Git dependencies are not supported. - Editable installations are not supported. - Local dependencies are not supported, unless they point to a specific wheel (.whl) or source archive (.zip, .tar.gz), as opposed to a directory.
-May also be set with the UV_REQUIRE_HASHES environment variable.
--requirements, --requirement, -r requirementsInstall all packages listed in the given requirements.txt or pylock.toml files.
+May also be set with the UV_REQUIRE_HASHES environment variable.
--requirements, --requirement, -r requirementsInstall all packages listed in the given requirements.txt, PEP 723 scripts, or pylock.toml 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.
--resolution resolutionThe strategy to use when selecting between the different compatible versions for a given package requirement.