From e3775635d4cdd24af3a923772afb0871bf7abb81 Mon Sep 17 00:00:00 2001 From: Trevor Manz Date: Thu, 10 Oct 2024 17:35:07 -0500 Subject: [PATCH] Support PEP 723 metadata with `uv run -` (#8111) ## Summary Fixes #8097. One challenge is that the `Pep723Script` is used for both reading and writing the metadata, so I wasn't sure about how to handle `script.write` when stdin (currently just ignoring it, but maybe we should raise an error?). ## Test Plan Added a test case copying the `test_stdin` with PEP 723 metadata. --- crates/uv-scripts/src/lib.rs | 43 ++++++++++++++++++------ crates/uv/src/commands/project/add.rs | 13 +++---- crates/uv/src/commands/project/remove.rs | 8 ++--- crates/uv/src/commands/project/run.rs | 32 +++++++++++++----- crates/uv/src/lib.rs | 13 ++++--- crates/uv/tests/run.rs | 36 ++++++++++++++++++++ 6 files changed, 109 insertions(+), 36 deletions(-) diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index c3e0d4b1c287..7ea275c6c65c 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -20,7 +20,7 @@ static FINDER: LazyLock = LazyLock::new(|| Finder::new(b"# /// script")) #[derive(Debug)] pub struct Pep723Script { /// The path to the Python script. - pub path: PathBuf, + pub source: Source, /// The parsed [`Pep723Metadata`] table from the script. pub metadata: Pep723Metadata, /// The content of the script before the metadata table. @@ -34,18 +34,28 @@ impl Pep723Script { /// /// See: pub async fn read(file: impl AsRef) -> Result, Pep723Error> { - let contents = match fs_err::tokio::read(&file).await { - Ok(contents) => contents, - Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), - Err(err) => return Err(err.into()), - }; + match fs_err::tokio::read(&file).await { + Ok(contents) => { + Self::parse_contents(&contents, Source::File(file.as_ref().to_path_buf())) + } + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.into()), + } + } + + /// Read the PEP 723 `script` metadata from stdin. + pub fn parse_stdin(contents: &[u8]) -> Result, Pep723Error> { + Self::parse_contents(contents, Source::Stdin) + } + /// Parse the contents of a Python script and extract the `script` metadata block. + fn parse_contents(contents: &[u8], source: Source) -> Result, Pep723Error> { // Extract the `script` tag. let ScriptTag { prelude, metadata, postlude, - } = match ScriptTag::parse(&contents) { + } = match ScriptTag::parse(contents) { Ok(Some(tag)) => tag, Ok(None) => return Ok(None), Err(err) => return Err(err), @@ -55,7 +65,7 @@ impl Pep723Script { let metadata = Pep723Metadata::from_str(&metadata)?; Ok(Some(Self { - path: file.as_ref().to_path_buf(), + source, metadata, prelude, postlude, @@ -84,7 +94,7 @@ impl Pep723Script { let (shebang, postlude) = extract_shebang(&contents)?; Ok(Self { - path: file.as_ref().to_path_buf(), + source: Source::File(file.as_ref().to_path_buf()), prelude: if shebang.is_empty() { String::new() } else { @@ -149,10 +159,23 @@ impl Pep723Script { self.postlude ); - Ok(fs_err::tokio::write(&self.path, content).await?) + if let Source::File(path) = &self.source { + fs_err::tokio::write(&path, content).await?; + } + + Ok(()) } } +/// The source of a PEP 723 script. +#[derive(Debug)] +pub enum Source { + /// The PEP 723 script is sourced from a file. + File(PathBuf), + /// The PEP 723 script is sourced from stdin. + Stdin, +} + /// PEP 723 metadata as parsed from a `script` comment block. /// /// See: diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 43f57d48d0de..b5f83bc31de9 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -379,7 +379,10 @@ pub(crate) async fn add( (uv_pep508::Requirement::from(requirement), None) } Target::Script(ref script, _) => { - let script_path = std::path::absolute(&script.path)?; + let uv_scripts::Source::File(path) = &script.source else { + unreachable!("script source is not a file"); + }; + let script_path = std::path::absolute(path)?; let script_dir = script_path.parent().expect("script path has no parent"); resolve_requirement( requirement, @@ -508,11 +511,9 @@ pub(crate) async fn add( Target::Project(project, venv) => (project, venv), // If `--script`, exit early. There's no reason to lock and sync. Target::Script(script, _) => { - writeln!( - printer.stderr(), - "Updated `{}`", - script.path.user_display().cyan() - )?; + if let uv_scripts::Source::File(path) = &script.source { + writeln!(printer.stderr(), "Updated `{}`", path.user_display().cyan())?; + } return Ok(ExitStatus::Success); } }; diff --git a/crates/uv/src/commands/project/remove.rs b/crates/uv/src/commands/project/remove.rs index fd0155c7727d..9a2c69906283 100644 --- a/crates/uv/src/commands/project/remove.rs +++ b/crates/uv/src/commands/project/remove.rs @@ -144,11 +144,9 @@ pub(crate) async fn remove( Target::Project(project) => project, // If `--script`, exit early. There's no reason to lock and sync. Target::Script(script) => { - writeln!( - printer.stderr(), - "Updated `{}`", - script.path.user_display().cyan() - )?; + if let uv_scripts::Source::File(path) = &script.source { + writeln!(printer.stderr(), "Updated `{}`", path.user_display().cyan())?; + } return Ok(ExitStatus::Success); } }; diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 635a0137ae6e..da90fa2f1123 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -107,11 +107,19 @@ pub(crate) async fn run( // Determine whether the command to execute is a PEP 723 script. let temp_dir; let script_interpreter = if let Some(script) = script { - writeln!( - printer.stderr(), - "Reading inline script metadata from: {}", - script.path.user_display().cyan() - )?; + if let uv_scripts::Source::File(path) = &script.source { + writeln!( + printer.stderr(), + "Reading inline script metadata from: {}", + path.user_display().cyan() + )?; + } else { + writeln!( + printer.stderr(), + "Reading inline script metadata from: `{}`", + "stdin".cyan() + )?; + } let (source, python_request) = if let Some(request) = python.as_deref() { // (1) Explicit request from user @@ -196,15 +204,23 @@ pub(crate) async fn run( .unwrap_or(&empty), SourceStrategy::Disabled => &empty, }; - let script_path = std::path::absolute(script.path)?; - let script_dir = script_path.parent().expect("script path has no parent"); + let script_dir = match &script.source { + uv_scripts::Source::File(path) => { + let script_path = std::path::absolute(path)?; + script_path + .parent() + .expect("script path has no parent") + .to_owned() + } + uv_scripts::Source::Stdin => std::env::current_dir()?, + }; let requirements = dependencies .into_iter() .flat_map(|requirement| { LoweredRequirement::from_non_workspace_requirement( requirement, - script_dir, + script_dir.as_ref(), script_sources, ) .map_ok(LoweredRequirement::into_inner) diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index d57623efae41..056fa260eb89 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -214,13 +214,12 @@ async fn run(mut cli: Cli) -> Result { // If the target is a PEP 723 script, parse it. let script = if let Commands::Project(command) = &*cli.command { if let ProjectCommand::Run(uv_cli::RunArgs { .. }) = &**command { - if let Some( - RunCommand::PythonScript(script, _) | RunCommand::PythonGuiScript(script, _), - ) = run_command.as_ref() - { - Pep723Script::read(&script).await? - } else { - None + match run_command.as_ref() { + Some( + RunCommand::PythonScript(script, _) | RunCommand::PythonGuiScript(script, _), + ) => Pep723Script::read(&script).await?, + Some(RunCommand::PythonStdin(contents)) => Pep723Script::parse_stdin(contents)?, + _ => None, } } else if let ProjectCommand::Remove(uv_cli::RemoveArgs { script: Some(script), diff --git a/crates/uv/tests/run.rs b/crates/uv/tests/run.rs index 15bc3d557570..db558a455216 100644 --- a/crates/uv/tests/run.rs +++ b/crates/uv/tests/run.rs @@ -2359,3 +2359,39 @@ fn run_url_like_with_local_file_priority() -> Result<()> { Ok(()) } + +#[test] +fn run_stdin_with_pep723() -> Result<()> { + let context = TestContext::new("3.12"); + + let test_script = context.temp_dir.child("main.py"); + test_script.write_str(indoc! { r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "iniconfig", + # ] + # /// + import iniconfig + print("Hello, world!") + "# + })?; + + let mut command = context.run(); + let command_with_args = command.stdin(std::fs::File::open(test_script)?).arg("-"); + uv_snapshot!(context.filters(), command_with_args, @r###" + success: true + exit_code: 0 + ----- stdout ----- + Hello, world! + + ----- stderr ----- + Reading inline script metadata from: `stdin` + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "###); + + Ok(()) +}