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-editable

Run 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-requirements

Run with all packages listed in the given requirements.txt files.

+
--with-requirements with-requirements

Run 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 with

Run with the given packages installed

--with-editable with-editable

Run 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-requirements

Run with all packages listed in the given requirements.txt files

+
--with-requirements with-requirements

Run 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 with

Include the following additional requirements

--with-editable with-editable

Include the given packages in editable mode

--with-executables-from with-executables-from

Install executables from the following packages

-
--with-requirements with-requirements

Include all requirements listed in the given requirements.txt files

+
--with-requirements with-requirements

Run 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 requirements

    Install 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 requirements

    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.

    If - is provided, then requirements will be read from stdin.

    --resolution resolution

    The strategy to use when selecting between the different compatible versions for a given package requirement.