From f7f159234f55b88d1de649308692f475f8b66e75 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 21 Nov 2025 11:53:41 -0500 Subject: [PATCH] Allow `--with-requirements` to load extensionless inline-metadata scripts (#16805) Reverts astral-sh/uv#16802 --- crates/uv-requirements/src/sources.rs | 55 +++++++++++++++++++-- crates/uv-requirements/src/specification.rs | 30 ++++++----- crates/uv-scripts/src/lib.rs | 53 ++++++++++++-------- crates/uv/tests/it/tool_run.rs | 39 +++++++++++++-- 4 files changed, 131 insertions(+), 46 deletions(-) diff --git a/crates/uv-requirements/src/sources.rs b/crates/uv-requirements/src/sources.rs index 8bba3213c..1ef25d399 100644 --- a/crates/uv-requirements/src/sources.rs +++ b/crates/uv-requirements/src/sources.rs @@ -6,6 +6,7 @@ use console::Term; use uv_fs::{CWD, Simplified}; use uv_requirements_txt::RequirementsTxtRequirement; +use uv_scripts::Pep723Script; #[derive(Debug, Clone)] pub enum RequirementsSource { @@ -14,7 +15,7 @@ pub enum RequirementsSource { /// 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), + Pep723Script(Box), /// 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`). @@ -50,8 +51,7 @@ impl RequirementsSource { .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)) + Ok(Self::Pep723Script(Pep723ScriptSource::new(path))) } else if path .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("toml")) @@ -60,6 +60,24 @@ impl RequirementsSource { "`{}` is not a valid PEP 751 filename: expected TOML file to start with `pylock.` and end with `.toml` (e.g., `pylock.toml`, `pylock.dev.toml`)", path.user_display(), )) + } else if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("txt") || ext.eq_ignore_ascii_case("in")) + { + Ok(Self::RequirementsTxt(path)) + } else if path == Path::new("-") { + // If the path is `-`, treat it as a requirements.txt file from stdin. + Ok(Self::RequirementsTxt(path)) + } else if path.extension().is_none() { + // If we don't have an extension, attempt to detect a PEP 723 script, and + // fall back to `requirements.txt` format if not. + match Pep723Script::read_sync(&path) { + Ok(Some(script)) => Ok(Self::Pep723Script(Pep723ScriptSource::with_script( + path, script, + ))), + Ok(None) => Ok(Self::RequirementsTxt(path)), + Err(err) => Err(err.into()), + } } else { Ok(Self::RequirementsTxt(path)) } @@ -291,14 +309,43 @@ impl RequirementsSource { } } +#[derive(Debug, Clone)] +pub struct Pep723ScriptSource { + path: PathBuf, + script: Option, +} + +impl Pep723ScriptSource { + fn new(path: PathBuf) -> Box { + Box::new(Self { path, script: None }) + } + + fn with_script(path: PathBuf, script: Pep723Script) -> Box { + Box::new(Self { + path, + script: Some(script), + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn script(&self) -> Option<&Pep723Script> { + self.script.as_ref() + } +} + impl std::fmt::Display for RequirementsSource { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Package(package) => write!(f, "{package:?}"), Self::Editable(path) => write!(f, "-e {path:?}"), + Self::Pep723Script(source) => { + write!(f, "{}", source.path().simplified_display()) + } 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 bb8d8adbf..5d862b7ab 100644 --- a/crates/uv-requirements/src/specification.rs +++ b/crates/uv-requirements/src/specification.rs @@ -46,7 +46,7 @@ use uv_fs::{CWD, Simplified}; use uv_normalize::{ExtraName, PackageName, PipGroupName}; use uv_pypi_types::PyProjectToml; use uv_requirements_txt::{RequirementsTxt, RequirementsTxtRequirement}; -use uv_scripts::{Pep723Error, Pep723Item, Pep723Script}; +use uv_scripts::{Pep723Item, Pep723Script}; use uv_warnings::warn_user; use crate::{RequirementsSource, SourceTree}; @@ -184,22 +184,20 @@ 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(), - )); + RequirementsSource::Pep723Script(source) => { + let script = if let Some(script) = source.script() { + Pep723Item::Script(script.clone()) + } else { + match Pep723Script::read(source.path()).await { + Ok(Some(script)) => Pep723Item::Script(script), + Ok(None) => { + return Err(anyhow::anyhow!( + "`{}` does not contain inline script metadata", + source.path().user_display(), + )); + } + Err(err) => return Err(err.into()), } - 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(); diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 17e4b0875..1e5c7e3d4 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -175,28 +175,16 @@ impl Pep723Script { /// /// See: pub async fn read(file: impl AsRef) -> Result, Pep723Error> { - let contents = fs_err::tokio::read(&file).await?; + let file = file.as_ref(); + let contents = fs_err::tokio::read(file).await?; + Self::from_contents(file, &contents) + } - // Extract the `script` tag. - let ScriptTag { - prelude, - metadata, - postlude, - } = match ScriptTag::parse(&contents) { - Ok(Some(tag)) => tag, - Ok(None) => return Ok(None), - Err(err) => return Err(err), - }; - - // Parse the metadata. - let metadata = Pep723Metadata::from_str(&metadata)?; - - Ok(Some(Self { - path: std::path::absolute(file)?, - metadata, - prelude, - postlude, - })) + /// Read the PEP 723 `script` metadata from a Python file using blocking I/O. + pub fn read_sync(file: impl AsRef) -> Result, Pep723Error> { + let file = file.as_ref(); + let contents = fs_err::read(file)?; + Self::from_contents(file, &contents) } /// Reads a Python script and generates a default PEP 723 metadata table. @@ -349,6 +337,29 @@ impl Pep723Script { .and_then(|uv| uv.sources.as_ref()) .unwrap_or(&EMPTY) } + + fn from_contents(path: &Path, contents: &[u8]) -> Result, Pep723Error> { + let script_tag = match ScriptTag::parse(contents) { + Ok(Some(tag)) => tag, + Ok(None) => return Ok(None), + Err(err) => return Err(err), + }; + + let ScriptTag { + prelude, + metadata, + postlude, + } = script_tag; + + let metadata = Pep723Metadata::from_str(&metadata)?; + + Ok(Some(Self { + path: std::path::absolute(path)?, + metadata, + prelude, + postlude, + })) + } } /// PEP 723 metadata as parsed from a `script` comment block. diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs index 07396b596..cf989c791 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -2646,8 +2646,7 @@ fn tool_run_with_incompatible_build_constraints() -> Result<()> { 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#" + let script_contents = indoc! {r#" # /// script # requires-python = ">=3.11" # dependencies = [ @@ -2656,7 +2655,13 @@ fn tool_run_with_dependencies_from_script() -> Result<()> { # /// import anyio - "#})?; + "#}; + + let script = context.temp_dir.child("script.py"); + script.write_str(script_contents)?; + + let script_without_extension = context.temp_dir.child("script-no-ext"); + script_without_extension.write_str(script_contents)?; // script dependencies (anyio) are now installed. uv_snapshot!(context.filters(), context.tool_run() @@ -2684,6 +2689,20 @@ fn tool_run_with_dependencies_from_script() -> Result<()> { + sniffio==1.3.1 "); + uv_snapshot!(context.filters(), context.tool_run() + .arg("--with-requirements") + .arg("script-no-ext") + .arg("black") + .arg("script-no-ext") + .arg("-q"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved [N] packages in [TIME] + "); + // 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")?; @@ -2700,8 +2719,18 @@ fn tool_run_with_dependencies_from_script() -> Result<()> { error: `not_pep723_script.py` does not contain inline script metadata "); + let filters = context + .filters() + .into_iter() + .chain([( + // The error message is different on Windows. + "The system cannot find the file specified.", + "No such file or directory", + )]) + .collect::>(); + // Error when the script doesn't exist. - uv_snapshot!(context.filters(), context.tool_run() + uv_snapshot!(filters, context.tool_run() .arg("--with-requirements") .arg("missing_file.py") .arg("black"), @r" @@ -2710,7 +2739,7 @@ fn tool_run_with_dependencies_from_script() -> Result<()> { ----- stdout ----- ----- stderr ----- - error: Failed to read `missing_file.py` (not found) + error: failed to read from file `missing_file.py`: No such file or directory (os error 2) "); Ok(())