Add a newline after metadata when initializing scripts with other metadata blocks (#12501)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / lint (push) Waiting to run
CI / cargo clippy | ubuntu (push) Blocked by required conditions
CI / cargo clippy | windows (push) Blocked by required conditions
CI / cargo dev generate-all (push) Blocked by required conditions
CI / cargo shear (push) Waiting to run
CI / cargo test | ubuntu (push) Blocked by required conditions
CI / cargo test | macos (push) Blocked by required conditions
CI / cargo test | windows (push) Blocked by required conditions
CI / check windows trampoline | aarch64 (push) Blocked by required conditions
CI / check cache | macos aarch64 (push) Blocked by required conditions
CI / check windows trampoline | i686 (push) Blocked by required conditions
CI / check windows trampoline | x86_64 (push) Blocked by required conditions
CI / test windows trampoline | i686 (push) Blocked by required conditions
CI / test windows trampoline | x86_64 (push) Blocked by required conditions
CI / typos (push) Waiting to run
CI / check system | python3.12 via chocolatey (push) Blocked by required conditions
CI / mkdocs (push) Waiting to run
CI / build binary | linux libc (push) Blocked by required conditions
CI / build binary | linux musl (push) Blocked by required conditions
CI / build binary | macos aarch64 (push) Blocked by required conditions
CI / build binary | macos x86_64 (push) Blocked by required conditions
CI / build binary | windows x86_64 (push) Blocked by required conditions
CI / build binary | windows aarch64 (push) Blocked by required conditions
CI / cargo build (msrv) (push) Blocked by required conditions
CI / build binary | freebsd (push) Blocked by required conditions
CI / ecosystem test | pydantic/pydantic-core (push) Blocked by required conditions
CI / ecosystem test | prefecthq/prefect (push) Blocked by required conditions
CI / ecosystem test | pallets/flask (push) Blocked by required conditions
CI / smoke test | linux (push) Blocked by required conditions
CI / check system | alpine (push) Blocked by required conditions
CI / smoke test | macos (push) Blocked by required conditions
CI / smoke test | windows x86_64 (push) Blocked by required conditions
CI / smoke test | windows aarch64 (push) Blocked by required conditions
CI / integration test | conda on ubuntu (push) Blocked by required conditions
CI / integration test | deadsnakes python3.9 on ubuntu (push) Blocked by required conditions
CI / integration test | free-threaded on linux (push) Blocked by required conditions
CI / integration test | free-threaded on windows (push) Blocked by required conditions
CI / integration test | pypy on ubuntu (push) Blocked by required conditions
CI / integration test | pypy on windows (push) Blocked by required conditions
CI / integration test | graalpy on ubuntu (push) Blocked by required conditions
CI / integration test | graalpy on windows (push) Blocked by required conditions
CI / integration test | github actions (push) Blocked by required conditions
CI / integration test | determine publish changes (push) Blocked by required conditions
CI / integration test | uv publish (push) Blocked by required conditions
CI / integration test | uv_build (push) Blocked by required conditions
CI / check cache | ubuntu (push) Blocked by required conditions
CI / check system | python on debian (push) Blocked by required conditions
CI / check system | python on fedora (push) Blocked by required conditions
CI / check system | python on ubuntu (push) Blocked by required conditions
CI / check system | python on opensuse (push) Blocked by required conditions
CI / check system | python on rocky linux 8 (push) Blocked by required conditions
CI / check system | python on rocky linux 9 (push) Blocked by required conditions
CI / check system | pypy on ubuntu (push) Blocked by required conditions
CI / check system | pyston (push) Blocked by required conditions
CI / check system | python on macos aarch64 (push) Blocked by required conditions
CI / check system | homebrew python on macos aarch64 (push) Blocked by required conditions
CI / check system | python on macos x86-64 (push) Blocked by required conditions
CI / check system | python3.10 on windows x86-64 (push) Blocked by required conditions
CI / check system | python3.10 on windows x86 (push) Blocked by required conditions
CI / check system | python3.13 on windows x86-64 (push) Blocked by required conditions
CI / check system | x86-64 python3.13 on windows aarch64 (push) Blocked by required conditions
CI / check system | windows registry (push) Blocked by required conditions
CI / check system | python3.9 via pyenv (push) Blocked by required conditions
CI / check system | python3.13 (push) Blocked by required conditions
CI / check system | conda3.11 on macos aarch64 (push) Blocked by required conditions
CI / check system | conda3.8 on macos aarch64 (push) Blocked by required conditions
CI / check system | conda3.11 on linux x86-64 (push) Blocked by required conditions
CI / check system | conda3.8 on linux x86-64 (push) Blocked by required conditions
CI / check system | conda3.11 on windows x86-64 (push) Blocked by required conditions
CI / check system | conda3.8 on windows x86-64 (push) Blocked by required conditions
CI / check system | amazonlinux (push) Blocked by required conditions
CI / check system | embedded python3.10 on windows x86-64 (push) Blocked by required conditions
CI / benchmarks (push) Blocked by required conditions

## Summary

uv doesn't separate the metadata block from other blocks when adding the
`script` block to a script, which results in the next block being
considered part of the script block and causes errors when running.

See #12499 for more details.

Closes #12499

## Test Plan

I manually tested the most common scenario, but there's a few edge cases
that would be good to have tests for.

I would have written the tests also, but I was running into errors like
this:
```bash
$ cargo test --package uv-scripts
   Compiling uv-configuration v0.0.1 (/home/merlin/Projects/uv/crates/uv-configuration)
error: cannot find attribute `value` in this scope
 --> crates/uv-configuration/src/project_build_backend.rs:8:38
  |
8 |     #[cfg_attr(feature = "schemars", value(hide = true))]
  |                                      ^^^^^

error: could not compile `uv-configuration` (lib) due to 1 previous error
```

---------

Co-authored-by: Charlie Marsh <charlie.r.marsh@gmail.com>
This commit is contained in:
Merlin 2025-03-27 17:39:29 -04:00 committed by GitHub
parent 9e10f83ce7
commit 5b2a8abef9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -158,7 +158,22 @@ impl Pep723Script {
requires_python: &VersionSpecifiers,
) -> Result<Self, Pep723Error> {
let contents = fs_err::tokio::read(&file).await?;
let (prelude, metadata, postlude) = Self::init_metadata(&contents, requires_python)?;
Ok(Self {
path: std::path::absolute(file)?,
metadata,
prelude,
postlude,
})
}
/// Generates a default PEP 723 metadata table from the provided script contents.
///
/// See: <https://peps.python.org/pep-0723/>
pub fn init_metadata(
contents: &[u8],
requires_python: &VersionSpecifiers,
) -> Result<(String, Pep723Metadata, String), Pep723Error> {
// Define the default metadata.
let default_metadata = indoc::formatdoc! {r#"
requires-python = "{requires_python}"
@ -168,19 +183,30 @@ impl Pep723Script {
};
let metadata = Pep723Metadata::from_str(&default_metadata)?;
// Extract the shebang and script content.
let (shebang, postlude) = extract_shebang(&contents)?;
// Extract the shebang and script content.
let (shebang, postlude) = extract_shebang(contents)?;
Ok(Self {
path: std::path::absolute(file)?,
prelude: if shebang.is_empty() {
// Add a newline to the beginning if it starts with a valid metadata comment line.
let postlude = if postlude.strip_prefix('#').is_some_and(|postlude| {
postlude
.chars()
.next()
.is_some_and(|c| matches!(c, ' ' | '\r' | '\n'))
}) {
format!("\n{postlude}")
} else {
postlude
};
Ok((
if shebang.is_empty() {
String::new()
} else {
format!("{shebang}\n")
},
metadata,
postlude,
})
))
}
/// Create a PEP 723 script at the given path.
@ -554,7 +580,7 @@ fn serialize_metadata(metadata: &str) -> String {
#[cfg(test)]
mod tests {
use crate::{serialize_metadata, Pep723Error, ScriptTag};
use crate::{serialize_metadata, Pep723Error, Pep723Script, ScriptTag};
#[test]
fn missing_space() {
@ -688,6 +714,7 @@ mod tests {
assert_eq!(actual.metadata, expected_metadata);
assert_eq!(actual.postlude, expected_data);
}
#[test]
fn embedded_comment() {
let contents = indoc::indoc! {r"
@ -751,7 +778,7 @@ mod tests {
}
#[test]
fn test_serialize_metadata_formatting() {
fn serialize_metadata_formatting() {
let metadata = indoc::indoc! {r"
requires-python = '>=3.11'
dependencies = [
@ -775,11 +802,250 @@ mod tests {
}
#[test]
fn test_serialize_metadata_empty() {
fn serialize_metadata_empty() {
let metadata = "";
let expected_output = "# /// script\n# ///\n";
let result = serialize_metadata(metadata);
assert_eq!(result, expected_output);
}
#[test]
fn script_init_empty() {
let contents = "".as_bytes();
let (prelude, metadata, postlude) =
Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
.unwrap();
assert_eq!(prelude, "");
assert_eq!(
metadata.raw,
indoc::indoc! {r#"
requires-python = ""
dependencies = []
"#}
);
assert_eq!(postlude, "");
}
#[test]
fn script_init_with_hashbang() {
let contents = indoc::indoc! {r#"
#!/usr/bin/env python3
print("Hello, world!")
"#}
.as_bytes();
let (prelude, metadata, postlude) =
Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
.unwrap();
assert_eq!(prelude, "#!/usr/bin/env python3\n");
assert_eq!(
metadata.raw,
indoc::indoc! {r#"
requires-python = ""
dependencies = []
"#}
);
assert_eq!(
postlude,
indoc::indoc! {r#"
print("Hello, world!")
"#}
);
}
#[test]
fn script_init_with_other_metadata() {
let contents = indoc::indoc! {r#"
# /// noscript
# Hello,
#
# World!
# ///
print("Hello, world!")
"#}
.as_bytes();
let (prelude, metadata, postlude) =
Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
.unwrap();
assert_eq!(prelude, "");
assert_eq!(
metadata.raw,
indoc::indoc! {r#"
requires-python = ""
dependencies = []
"#}
);
// Note the extra line at the beginning
assert_eq!(
postlude,
indoc::indoc! {r#"
# /// noscript
# Hello,
#
# World!
# ///
print("Hello, world!")
"#}
);
}
#[test]
fn script_init_with_hashbang_and_other_metadata() {
let contents = indoc::indoc! {r#"
#!/usr/bin/env python3
# /// noscript
# Hello,
#
# World!
# ///
print("Hello, world!")
"#}
.as_bytes();
let (prelude, metadata, postlude) =
Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
.unwrap();
assert_eq!(prelude, "#!/usr/bin/env python3\n");
assert_eq!(
metadata.raw,
indoc::indoc! {r#"
requires-python = ""
dependencies = []
"#}
);
// Note the extra line at the beginning.
assert_eq!(
postlude,
indoc::indoc! {r#"
# /// noscript
# Hello,
#
# World!
# ///
print("Hello, world!")
"#}
);
}
#[test]
fn script_init_with_valid_metadata_line() {
let contents = indoc::indoc! {r#"
# Hello,
# /// noscript
#
# World!
# ///
print("Hello, world!")
"#}
.as_bytes();
let (prelude, metadata, postlude) =
Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
.unwrap();
assert_eq!(prelude, "");
assert_eq!(
metadata.raw,
indoc::indoc! {r#"
requires-python = ""
dependencies = []
"#}
);
// Note the extra line at the beginning
assert_eq!(
postlude,
indoc::indoc! {r#"
# Hello,
# /// noscript
#
# World!
# ///
print("Hello, world!")
"#}
);
}
#[test]
fn script_init_with_valid_empty_metadata_line() {
let contents = indoc::indoc! {r#"
#
# /// noscript
# Hello,
# World!
# ///
print("Hello, world!")
"#}
.as_bytes();
let (prelude, metadata, postlude) =
Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
.unwrap();
assert_eq!(prelude, "");
assert_eq!(
metadata.raw,
indoc::indoc! {r#"
requires-python = ""
dependencies = []
"#}
);
// Note the extra line at the beginning
assert_eq!(
postlude,
indoc::indoc! {r#"
#
# /// noscript
# Hello,
# World!
# ///
print("Hello, world!")
"#}
);
}
#[test]
fn script_init_with_non_metadata_comment() {
let contents = indoc::indoc! {r#"
#Hello,
# /// noscript
#
# World!
# ///
print("Hello, world!")
"#}
.as_bytes();
let (prelude, metadata, postlude) =
Pep723Script::init_metadata(contents, &uv_pep440::VersionSpecifiers::default())
.unwrap();
assert_eq!(prelude, "");
assert_eq!(
metadata.raw,
indoc::indoc! {r#"
requires-python = ""
dependencies = []
"#}
);
assert_eq!(
postlude,
indoc::indoc! {r#"
#Hello,
# /// noscript
#
# World!
# ///
print("Hello, world!")
"#}
);
}
}