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.
This commit is contained in:
Trevor Manz 2024-10-10 17:35:07 -05:00 committed by GitHub
parent 0627b4a8a4
commit e3775635d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 109 additions and 36 deletions

View file

@ -20,7 +20,7 @@ static FINDER: LazyLock<Finder> = LazyLock::new(|| Finder::new(b"# /// script"))
#[derive(Debug)] #[derive(Debug)]
pub struct Pep723Script { pub struct Pep723Script {
/// The path to the Python script. /// The path to the Python script.
pub path: PathBuf, pub source: Source,
/// The parsed [`Pep723Metadata`] table from the script. /// The parsed [`Pep723Metadata`] table from the script.
pub metadata: Pep723Metadata, pub metadata: Pep723Metadata,
/// The content of the script before the metadata table. /// The content of the script before the metadata table.
@ -34,18 +34,28 @@ impl Pep723Script {
/// ///
/// See: <https://peps.python.org/pep-0723/> /// See: <https://peps.python.org/pep-0723/>
pub async fn read(file: impl AsRef<Path>) -> Result<Option<Self>, Pep723Error> { pub async fn read(file: impl AsRef<Path>) -> Result<Option<Self>, Pep723Error> {
let contents = match fs_err::tokio::read(&file).await { match fs_err::tokio::read(&file).await {
Ok(contents) => contents, Ok(contents) => {
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), Self::parse_contents(&contents, Source::File(file.as_ref().to_path_buf()))
Err(err) => return Err(err.into()), }
}; 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<Option<Self>, 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<Option<Self>, Pep723Error> {
// Extract the `script` tag. // Extract the `script` tag.
let ScriptTag { let ScriptTag {
prelude, prelude,
metadata, metadata,
postlude, postlude,
} = match ScriptTag::parse(&contents) { } = match ScriptTag::parse(contents) {
Ok(Some(tag)) => tag, Ok(Some(tag)) => tag,
Ok(None) => return Ok(None), Ok(None) => return Ok(None),
Err(err) => return Err(err), Err(err) => return Err(err),
@ -55,7 +65,7 @@ impl Pep723Script {
let metadata = Pep723Metadata::from_str(&metadata)?; let metadata = Pep723Metadata::from_str(&metadata)?;
Ok(Some(Self { Ok(Some(Self {
path: file.as_ref().to_path_buf(), source,
metadata, metadata,
prelude, prelude,
postlude, postlude,
@ -84,7 +94,7 @@ impl Pep723Script {
let (shebang, postlude) = extract_shebang(&contents)?; let (shebang, postlude) = extract_shebang(&contents)?;
Ok(Self { Ok(Self {
path: file.as_ref().to_path_buf(), source: Source::File(file.as_ref().to_path_buf()),
prelude: if shebang.is_empty() { prelude: if shebang.is_empty() {
String::new() String::new()
} else { } else {
@ -149,10 +159,23 @@ impl Pep723Script {
self.postlude 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. /// PEP 723 metadata as parsed from a `script` comment block.
/// ///
/// See: <https://peps.python.org/pep-0723/> /// See: <https://peps.python.org/pep-0723/>

View file

@ -379,7 +379,10 @@ pub(crate) async fn add(
(uv_pep508::Requirement::from(requirement), None) (uv_pep508::Requirement::from(requirement), None)
} }
Target::Script(ref script, _) => { 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"); let script_dir = script_path.parent().expect("script path has no parent");
resolve_requirement( resolve_requirement(
requirement, requirement,
@ -508,11 +511,9 @@ pub(crate) async fn add(
Target::Project(project, venv) => (project, venv), Target::Project(project, venv) => (project, venv),
// If `--script`, exit early. There's no reason to lock and sync. // If `--script`, exit early. There's no reason to lock and sync.
Target::Script(script, _) => { Target::Script(script, _) => {
writeln!( if let uv_scripts::Source::File(path) = &script.source {
printer.stderr(), writeln!(printer.stderr(), "Updated `{}`", path.user_display().cyan())?;
"Updated `{}`", }
script.path.user_display().cyan()
)?;
return Ok(ExitStatus::Success); return Ok(ExitStatus::Success);
} }
}; };

View file

@ -144,11 +144,9 @@ pub(crate) async fn remove(
Target::Project(project) => project, Target::Project(project) => project,
// If `--script`, exit early. There's no reason to lock and sync. // If `--script`, exit early. There's no reason to lock and sync.
Target::Script(script) => { Target::Script(script) => {
writeln!( if let uv_scripts::Source::File(path) = &script.source {
printer.stderr(), writeln!(printer.stderr(), "Updated `{}`", path.user_display().cyan())?;
"Updated `{}`", }
script.path.user_display().cyan()
)?;
return Ok(ExitStatus::Success); return Ok(ExitStatus::Success);
} }
}; };

View file

@ -107,11 +107,19 @@ pub(crate) async fn run(
// Determine whether the command to execute is a PEP 723 script. // Determine whether the command to execute is a PEP 723 script.
let temp_dir; let temp_dir;
let script_interpreter = if let Some(script) = script { let script_interpreter = if let Some(script) = script {
writeln!( if let uv_scripts::Source::File(path) = &script.source {
printer.stderr(), writeln!(
"Reading inline script metadata from: {}", printer.stderr(),
script.path.user_display().cyan() "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() { let (source, python_request) = if let Some(request) = python.as_deref() {
// (1) Explicit request from user // (1) Explicit request from user
@ -196,15 +204,23 @@ pub(crate) async fn run(
.unwrap_or(&empty), .unwrap_or(&empty),
SourceStrategy::Disabled => &empty, SourceStrategy::Disabled => &empty,
}; };
let script_path = std::path::absolute(script.path)?; let script_dir = match &script.source {
let script_dir = script_path.parent().expect("script path has no parent"); 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 let requirements = dependencies
.into_iter() .into_iter()
.flat_map(|requirement| { .flat_map(|requirement| {
LoweredRequirement::from_non_workspace_requirement( LoweredRequirement::from_non_workspace_requirement(
requirement, requirement,
script_dir, script_dir.as_ref(),
script_sources, script_sources,
) )
.map_ok(LoweredRequirement::into_inner) .map_ok(LoweredRequirement::into_inner)

View file

@ -214,13 +214,12 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
// If the target is a PEP 723 script, parse it. // If the target is a PEP 723 script, parse it.
let script = if let Commands::Project(command) = &*cli.command { let script = if let Commands::Project(command) = &*cli.command {
if let ProjectCommand::Run(uv_cli::RunArgs { .. }) = &**command { if let ProjectCommand::Run(uv_cli::RunArgs { .. }) = &**command {
if let Some( match run_command.as_ref() {
RunCommand::PythonScript(script, _) | RunCommand::PythonGuiScript(script, _), Some(
) = run_command.as_ref() RunCommand::PythonScript(script, _) | RunCommand::PythonGuiScript(script, _),
{ ) => Pep723Script::read(&script).await?,
Pep723Script::read(&script).await? Some(RunCommand::PythonStdin(contents)) => Pep723Script::parse_stdin(contents)?,
} else { _ => None,
None
} }
} else if let ProjectCommand::Remove(uv_cli::RemoveArgs { } else if let ProjectCommand::Remove(uv_cli::RemoveArgs {
script: Some(script), script: Some(script),

View file

@ -2359,3 +2359,39 @@ fn run_url_like_with_local_file_priority() -> Result<()> {
Ok(()) 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(())
}