diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index cccac5591..29ca48300 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; use std::env::VarError; -use std::ffi::OsString; +use std::ffi::{OsStr, OsString}; use std::fmt::Write; use std::io::Read; use std::path::{Path, PathBuf}; @@ -1116,6 +1116,58 @@ fn can_skip_ephemeral( } } +#[derive(Debug)] +enum WindowsScript { + /// `PowerShell` script (.ps1) + PowerShell, + /// Command Prompt NT script (.cmd) + Command, + /// Command Prompt script (.bat) + Batch, +} + +impl WindowsScript { + /// Returns a list of all supported Windows script types. + fn all() -> &'static [Self] { + &[Self::PowerShell, Self::Command, Self::Batch] + } + + /// Returns the script extension for a given Windows script type. + fn to_extension(&self) -> &'static str { + match self { + Self::PowerShell => "ps1", + Self::Command => "cmd", + Self::Batch => "bat", + } + } + + /// Determines the script type from a given Windows file extension. + fn from_extension(ext: &str) -> Option { + match ext { + "ps1" => Some(Self::PowerShell), + "cmd" => Some(Self::Command), + "bat" => Some(Self::Batch), + _ => None, + } + } + + /// Returns a [`Command`] to run the given script under the appropriate Windows command. + fn as_command(&self, script: &Path) -> Command { + match self { + Self::PowerShell => { + let mut cmd = Command::new("powershell"); + cmd.arg("-NoLogo").arg("-File").arg(script); + cmd + } + Self::Command | Self::Batch => { + let mut cmd = Command::new("cmd"); + cmd.arg("/q").arg("/c").arg(script); + cmd + } + } + } +} + #[derive(Debug)] pub(crate) enum RunCommand { /// Execute `python`. @@ -1177,6 +1229,37 @@ impl RunCommand { } } + /// Handle legacy setuptools scripts for Windows. + /// + /// Returns [`Command`] that can be used to run `.ps1`, `.cmd`, or `.bat` scripts on Windows. + fn for_windows_script(interpreter: &Interpreter, executable: &OsStr) -> Command { + let script_path = interpreter.scripts().join(executable); + + // Honor explicit extension if provided and recognized. + if let Some(script_type) = script_path + .extension() + .and_then(OsStr::to_str) + .and_then(WindowsScript::from_extension) + .filter(|_| script_path.is_file()) + { + return script_type.as_command(&script_path); + } + + // Guess the extension when an explicit one is not provided. + // We also add the extension when missing since for PowerShell it must be explicit. + WindowsScript::all() + .iter() + .map(|script_type| { + ( + script_type, + script_path.with_extension(script_type.to_extension()), + ) + }) + .find(|(_, script_path)| script_path.is_file()) + .map(|(script_type, script_path)| script_type.as_command(&script_path)) + .unwrap_or_else(|| Command::new(executable)) + } + /// Convert a [`RunCommand`] into a [`Command`]. fn as_command(&self, interpreter: &Interpreter) -> Command { match self { @@ -1292,7 +1375,11 @@ impl RunCommand { process } Self::External(executable, args) => { - let mut process = Command::new(executable); + let mut process = if cfg!(windows) { + Self::for_windows_script(interpreter, executable) + } else { + Command::new(executable) + }; process.args(args); process } diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 4c36be01e..9e458ce16 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -4372,3 +4372,234 @@ fn run_uv_variable() { ----- stderr ----- "###); } + +/// Test legacy scripts . +/// +/// This tests for execution and detection of legacy windows scripts with .bat, .cmd, and .ps1 extensions. +#[cfg(windows)] +#[test] +fn run_windows_legacy_scripts() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + + // Use `script-files` which enables legacy scripts packaging. + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.8" + dependencies = [] + + [tool.setuptools] + packages = [] + script-files = [ + "misc/custom_pydoc.bat", + "misc/custom_pydoc.cmd", + "misc/custom_pydoc.ps1" + ] + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "# + })?; + + let custom_pydoc_bat = context.temp_dir.child("misc").child("custom_pydoc.bat"); + let custom_pydoc_cmd = context.temp_dir.child("misc").child("custom_pydoc.cmd"); + let custom_pydoc_ps1 = context.temp_dir.child("misc").child("custom_pydoc.ps1"); + + custom_pydoc_bat.write_str("python.exe -m pydoc %*")?; + custom_pydoc_cmd.write_str("python.exe -m pydoc %*")?; + custom_pydoc_ps1.write_str("python.exe -m pydoc $args")?; + + uv_snapshot!(context.filters(), context.run(), @r###" + success: false + exit_code: 2 + ----- stdout ----- + Provide a command or script to invoke with `uv run ` or `uv run