diff --git a/Cargo.lock b/Cargo.lock index 264a17e55..77a17a4f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5742,6 +5742,7 @@ dependencies = [ "fs-err 3.1.1", "indoc", "memchr", + "regex", "serde", "thiserror 2.0.12", "toml", @@ -5751,6 +5752,7 @@ dependencies = [ "uv-pypi-types", "uv-redacted", "uv-settings", + "uv-warnings", "uv-workspace", ] diff --git a/crates/uv-scripts/Cargo.toml b/crates/uv-scripts/Cargo.toml index 993633918..124eb1fea 100644 --- a/crates/uv-scripts/Cargo.toml +++ b/crates/uv-scripts/Cargo.toml @@ -16,11 +16,13 @@ uv-pep508 = { workspace = true } uv-pypi-types = { workspace = true } uv-redacted = { workspace = true } uv-settings = { workspace = true } +uv-warnings = { workspace = true } uv-workspace = { workspace = true } fs-err = { workspace = true, features = ["tokio"] } indoc = { workspace = true } memchr = { workspace = true } +regex = { workspace = true } serde = { workspace = true, features = ["derive"] } thiserror = { workspace = true } toml = { workspace = true } diff --git a/crates/uv-scripts/src/lib.rs b/crates/uv-scripts/src/lib.rs index 1023b4141..b80cdc219 100644 --- a/crates/uv-scripts/src/lib.rs +++ b/crates/uv-scripts/src/lib.rs @@ -14,6 +14,7 @@ use uv_pep508::PackageName; use uv_pypi_types::VerbatimParsedUrl; use uv_redacted::DisplaySafeUrl; use uv_settings::{GlobalOptions, ResolverInstallerOptions}; +use uv_warnings::warn_user; use uv_workspace::pyproject::Sources; static FINDER: LazyLock = LazyLock::new(|| Finder::new(b"# /// script")); @@ -238,11 +239,25 @@ impl Pep723Script { let metadata = serialize_metadata(&default_metadata); let script = if let Some(existing_contents) = existing_contents { + let (mut shebang, contents) = extract_shebang(&existing_contents)?; + if !shebang.is_empty() { + shebang.push_str("\n#\n"); + // If the shebang doesn't contain `uv`, it's probably something like + // `#! /usr/bin/env python`, which isn't going to respect the inline metadata. + // Issue a warning for users who might not know that. + // TODO: There are a lot of mistakes we could consider detecting here, like + // `uv run` without `--script` when the file doesn't end in `.py`. + if !regex::Regex::new(r"\buv\b").unwrap().is_match(&shebang) { + warn_user!( + "If you execute {} directly, it might ignore its inline metadata.\nConsider replacing its shebang with: {}", + file.to_string_lossy().cyan(), + "#!/usr/bin/env -S uv run --script".cyan(), + ); + } + } indoc::formatdoc! {r" - {metadata} - {content} - ", - content = String::from_utf8(existing_contents).map_err(|err| Pep723Error::Utf8(err.utf8_error()))?} + {shebang}{metadata} + {contents}" } } else { indoc::formatdoc! {r#" {metadata} diff --git a/crates/uv-warnings/src/lib.rs b/crates/uv-warnings/src/lib.rs index 9ed4c646e..5f2287cac 100644 --- a/crates/uv-warnings/src/lib.rs +++ b/crates/uv-warnings/src/lib.rs @@ -24,7 +24,7 @@ pub fn disable() { /// Warn a user, if warnings are enabled. #[macro_export] macro_rules! warn_user { - ($($arg:tt)*) => { + ($($arg:tt)*) => {{ use $crate::anstream::eprintln; use $crate::owo_colors::OwoColorize; @@ -33,7 +33,7 @@ macro_rules! warn_user { let formatted = message.bold(); eprintln!("{}{} {formatted}", "warning".yellow().bold(), ":".bold()); } - }; + }}; } pub static WARNINGS: LazyLock>> = LazyLock::new(Mutex::default); @@ -42,7 +42,7 @@ pub static WARNINGS: LazyLock>> = LazyLock::new(Mutex::d /// message. #[macro_export] macro_rules! warn_user_once { - ($($arg:tt)*) => { + ($($arg:tt)*) => {{ use $crate::anstream::eprintln; use $crate::owo_colors::OwoColorize; @@ -54,5 +54,5 @@ macro_rules! warn_user_once { } } } - }; + }}; } diff --git a/crates/uv/tests/it/init.rs b/crates/uv/tests/it/init.rs index e9e5e54a7..c5993d670 100644 --- a/crates/uv/tests/it/init.rs +++ b/crates/uv/tests/it/init.rs @@ -929,6 +929,65 @@ fn init_script_file_conflicts() -> Result<()> { Ok(()) } +// Init script should not trash an existing shebang. +#[test] +fn init_script_shebang() -> Result<()> { + let context = TestContext::new("3.12"); + + let script_path = context.temp_dir.child("script.py"); + + let contents = "#! /usr/bin/env python3\nprint(\"Hello, world!\")"; + fs_err::write(&script_path, contents)?; + uv_snapshot!(context.filters(), context.init().arg("--script").arg("script.py"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + warning: If you execute script.py directly, it might ignore its inline metadata. + Consider replacing its shebang with: #!/usr/bin/env -S uv run --script + Initialized script at `script.py` + "); + let resulting_script = fs_err::read_to_string(&script_path)?; + assert_snapshot!(resulting_script, @r#" + #! /usr/bin/env python3 + # + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + + print("Hello, world!") + "# + ); + + // If the shebang already contains `uv`, the result is the same, but we suppress the warning. + let contents = "#!/usr/bin/env -S uv run --script\nprint(\"Hello, world!\")"; + fs_err::write(&script_path, contents)?; + uv_snapshot!(context.filters(), context.init().arg("--script").arg("script.py"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Initialized script at `script.py` + "); + let resulting_script = fs_err::read_to_string(&script_path)?; + assert_snapshot!(resulting_script, @r#" + #!/usr/bin/env -S uv run --script + # + # /// script + # requires-python = ">=3.12" + # dependencies = [] + # /// + + print("Hello, world!") + "# + ); + + Ok(()) +} + /// Run `uv init --lib` with an existing py.typed file #[test] fn init_py_typed_exists() -> Result<()> { diff --git a/docs/guides/scripts.md b/docs/guides/scripts.md index 7142db155..26d85e76d 100644 --- a/docs/guides/scripts.md +++ b/docs/guides/scripts.md @@ -241,10 +241,12 @@ Declaration of dependencies is also supported in this context, for example: ```python title="example" #!/usr/bin/env -S uv run --script +# # /// script # requires-python = ">=3.12" # dependencies = ["httpx"] # /// + import httpx print(httpx.get("https://example.com"))