Add support for Windows legacy scripts via uv run (#11888)

## Summary

Closes https://github.com/astral-sh/uv/issues/9151

This adds support for running .ps1, .cmd, .bat legacy scripts typically
provided by setuptools [legacy script
files](https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/#scripts).

Note, .bat and .cmd scripts were somewhat supported previously by
[Command](https://doc.rust-lang.org/std/process/index.html#batch-file-special-handling)
when the extension was explicit but documentation says such behavior
should not be relied upon.

In addition, when no extension is provided and a legacy script exists,
it will try to infer the appropriate extension on Windows and use the
right runtime with preference for .ps1. Only powershell.exe and cmd.exe
are supported right now.

## Test Plan

Added tests. Tested with nuitka locally via uv run.

Note uvx support will be added in a follow up.
This commit is contained in:
samypr100 2025-03-05 15:39:48 -05:00 committed by GitHub
parent 28ff80b639
commit 0fb5291239
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 320 additions and 2 deletions

View File

@ -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<Self> {
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
}

View File

@ -4372,3 +4372,234 @@ fn run_uv_variable() {
----- stderr -----
"###);
}
/// Test legacy scripts <https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/#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 <command>` or `uv run <script>.py`.
The following commands are available in the environment:
- custom_pydoc.bat
- custom_pydoc.cmd
- custom_pydoc.ps1
- pydoc.bat
- python
- pythonw
See `uv run --help` for more information.
----- stderr -----
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ foo==1.0.0 (from file://[TEMP_DIR]/)
"###);
// Test with explicit .bat extension
uv_snapshot!(context.filters(), context.run().arg("custom_pydoc.bat"), @r###"
success: true
exit_code: 0
----- stdout -----
pydoc - the Python documentation tool
pydoc <name> ...
Show text documentation on something. <name> may be the name of a
Python keyword, topic, function, module, or package, or a dotted
reference to a class or function within a module or module in a
package. If <name> contains a '\', it is used as the path to a
Python source file to document. If name is 'keywords', 'topics',
or 'modules', a listing of these things is displayed.
pydoc -k <keyword>
Search for a keyword in the synopsis lines of all available modules.
pydoc -n <hostname>
Start an HTTP server with the given hostname (default: localhost).
pydoc -p <port>
Start an HTTP server on the given port on the local machine. Port
number 0 can be used to get an arbitrary unused port.
pydoc -b
Start an HTTP server on an arbitrary unused port and open a web browser
to interactively browse documentation. This option can be used in
combination with -n and/or -p.
pydoc -w <name> ...
Write out the HTML documentation for a module to a file in the current
directory. If <name> contains a '\', it is treated as a filename; if
it names a directory, documentation is written for all the contents.
----- stderr -----
Resolved 1 package in [TIME]
Audited 1 package in [TIME]
"###);
// Test with explicit .cmd extension
uv_snapshot!(context.filters(), context.run().arg("custom_pydoc.cmd"), @r###"
success: true
exit_code: 0
----- stdout -----
pydoc - the Python documentation tool
pydoc <name> ...
Show text documentation on something. <name> may be the name of a
Python keyword, topic, function, module, or package, or a dotted
reference to a class or function within a module or module in a
package. If <name> contains a '\', it is used as the path to a
Python source file to document. If name is 'keywords', 'topics',
or 'modules', a listing of these things is displayed.
pydoc -k <keyword>
Search for a keyword in the synopsis lines of all available modules.
pydoc -n <hostname>
Start an HTTP server with the given hostname (default: localhost).
pydoc -p <port>
Start an HTTP server on the given port on the local machine. Port
number 0 can be used to get an arbitrary unused port.
pydoc -b
Start an HTTP server on an arbitrary unused port and open a web browser
to interactively browse documentation. This option can be used in
combination with -n and/or -p.
pydoc -w <name> ...
Write out the HTML documentation for a module to a file in the current
directory. If <name> contains a '\', it is treated as a filename; if
it names a directory, documentation is written for all the contents.
----- stderr -----
Resolved 1 package in [TIME]
Audited 1 package in [TIME]
"###);
// Test with explicit .ps1 extension
uv_snapshot!(context.filters(), context.run().arg("custom_pydoc.ps1"), @r###"
success: true
exit_code: 0
----- stdout -----
pydoc - the Python documentation tool
pydoc <name> ...
Show text documentation on something. <name> may be the name of a
Python keyword, topic, function, module, or package, or a dotted
reference to a class or function within a module or module in a
package. If <name> contains a '\', it is used as the path to a
Python source file to document. If name is 'keywords', 'topics',
or 'modules', a listing of these things is displayed.
pydoc -k <keyword>
Search for a keyword in the synopsis lines of all available modules.
pydoc -n <hostname>
Start an HTTP server with the given hostname (default: localhost).
pydoc -p <port>
Start an HTTP server on the given port on the local machine. Port
number 0 can be used to get an arbitrary unused port.
pydoc -b
Start an HTTP server on an arbitrary unused port and open a web browser
to interactively browse documentation. This option can be used in
combination with -n and/or -p.
pydoc -w <name> ...
Write out the HTML documentation for a module to a file in the current
directory. If <name> contains a '\', it is treated as a filename; if
it names a directory, documentation is written for all the contents.
----- stderr -----
Resolved 1 package in [TIME]
Audited 1 package in [TIME]
"###);
// Test without explicit extension (.ps1 should be used)
uv_snapshot!(context.filters(), context.run().arg("custom_pydoc"), @r###"
success: true
exit_code: 0
----- stdout -----
pydoc - the Python documentation tool
pydoc <name> ...
Show text documentation on something. <name> may be the name of a
Python keyword, topic, function, module, or package, or a dotted
reference to a class or function within a module or module in a
package. If <name> contains a '\', it is used as the path to a
Python source file to document. If name is 'keywords', 'topics',
or 'modules', a listing of these things is displayed.
pydoc -k <keyword>
Search for a keyword in the synopsis lines of all available modules.
pydoc -n <hostname>
Start an HTTP server with the given hostname (default: localhost).
pydoc -p <port>
Start an HTTP server on the given port on the local machine. Port
number 0 can be used to get an arbitrary unused port.
pydoc -b
Start an HTTP server on an arbitrary unused port and open a web browser
to interactively browse documentation. This option can be used in
combination with -n and/or -p.
pydoc -w <name> ...
Write out the HTML documentation for a module to a file in the current
directory. If <name> contains a '\', it is treated as a filename; if
it names a directory, documentation is written for all the contents.
----- stderr -----
Resolved 1 package in [TIME]
Audited 1 package in [TIME]
"###);
Ok(())
}