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

## Summary

Follow up to https://github.com/astral-sh/uv/pull/11888 with added
support for uv tool run.

Changes
* Added functionality for running windows scripts in previous PR was
moved from run.rs to uv_shell::runnable.
* EXE was added as a supported type, this simplified integration across
both uv run and uvx while retaining a backwards compatible behavior and
properly prioritizing .exe over others. Name was adjusted to runnable as
a result to better represent intent.

## Test Plan

New tests added.

## Documentation

Added new documentation.
This commit is contained in:
samypr100 2025-03-11 10:02:17 -04:00 committed by GitHub
parent 82212bb439
commit e096ab2411
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 474 additions and 87 deletions

View File

@ -1,3 +1,4 @@
pub mod runnable;
mod shlex;
pub mod windows;

View File

@ -0,0 +1,100 @@
//! Utilities for running executables and scripts. Particularly in Windows.
use std::env::consts::EXE_EXTENSION;
use std::ffi::OsStr;
use std::path::Path;
use std::process::Command;
#[derive(Debug)]
pub enum WindowsRunnable {
/// Windows PE (.exe)
Executable,
/// `PowerShell` script (.ps1)
PowerShell,
/// Command Prompt NT script (.cmd)
Command,
/// Command Prompt script (.bat)
Batch,
}
impl WindowsRunnable {
/// Returns a list of all supported Windows runnable types.
fn all() -> &'static [Self] {
&[
Self::Executable,
Self::PowerShell,
Self::Command,
Self::Batch,
]
}
/// Returns the extension for a given Windows runnable type.
fn to_extension(&self) -> &'static str {
match self {
Self::Executable => EXE_EXTENSION,
Self::PowerShell => "ps1",
Self::Command => "cmd",
Self::Batch => "bat",
}
}
/// Determines the runnable type from a given Windows file extension.
fn from_extension(ext: &str) -> Option<Self> {
match ext {
EXE_EXTENSION => Some(Self::Executable),
"ps1" => Some(Self::PowerShell),
"cmd" => Some(Self::Command),
"bat" => Some(Self::Batch),
_ => None,
}
}
/// Returns a [`Command`] to run the given type under the appropriate Windows runtime.
fn as_command(&self, runnable_path: &Path) -> Command {
match self {
Self::Executable => Command::new(runnable_path),
Self::PowerShell => {
let mut cmd = Command::new("powershell");
cmd.arg("-NoLogo").arg("-File").arg(runnable_path);
cmd
}
Self::Command | Self::Batch => {
let mut cmd = Command::new("cmd");
cmd.arg("/q").arg("/c").arg(runnable_path);
cmd
}
}
}
/// Handle console and legacy setuptools scripts for Windows.
///
/// Returns [`Command`] that can be used to invoke a supported runnable on Windows
/// under the scripts path of an interpreter environment.
pub fn from_script_path(script_path: &Path, runnable_name: &OsStr) -> Command {
let script_path = script_path.join(runnable_name);
// Honor explicit extension if provided and recognized.
if let Some(script_type) = script_path
.extension()
.and_then(OsStr::to_str)
.and_then(Self::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 some types (e.g. PowerShell) it must be explicit.
Self::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(runnable_name))
}
}

View File

@ -1,6 +1,6 @@
use std::borrow::Cow;
use std::env::VarError;
use std::ffi::{OsStr, OsString};
use std::ffi::OsString;
use std::fmt::Write;
use std::io::Read;
use std::path::{Path, PathBuf};
@ -33,6 +33,7 @@ use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_resolver::Lock;
use uv_scripts::Pep723Item;
use uv_settings::PythonInstallMirrors;
use uv_shell::runnable::WindowsRunnable;
use uv_static::EnvVars;
use uv_warnings::warn_user;
use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceCache, WorkspaceError};
@ -1126,58 +1127,6 @@ 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`.
@ -1239,37 +1188,6 @@ 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 {
@ -1386,7 +1304,7 @@ impl RunCommand {
}
Self::External(executable, args) => {
let mut process = if cfg!(windows) {
Self::for_windows_script(interpreter, executable)
WindowsRunnable::from_script_path(interpreter.scripts(), executable).into()
} else {
Command::new(executable)
};

View File

@ -34,6 +34,7 @@ use uv_python::{
};
use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions};
use uv_shell::runnable::WindowsRunnable;
use uv_static::EnvVars;
use uv_tool::{entrypoint_paths, InstalledTools};
use uv_warnings::warn_user;
@ -260,7 +261,12 @@ pub(crate) async fn run(
let executable = from.executable();
// Construct the command
let mut process = Command::new(executable);
let mut process = if cfg!(windows) {
WindowsRunnable::from_script_path(environment.scripts(), executable.as_ref()).into()
} else {
Command::new(executable)
};
process.args(args);
// Construct the `PATH` environment variable.

View File

@ -4560,7 +4560,7 @@ fn run_windows_legacy_scripts() -> Result<()> {
Audited 1 package in [TIME]
"###);
// Test without explicit extension (.ps1 should be used)
// Test without explicit extension (.ps1 should be used) as there's no .exe available.
uv_snapshot!(context.filters(), context.run().arg("custom_pydoc"), @r###"
success: true
exit_code: 0

View File

@ -2259,3 +2259,323 @@ fn tool_run_with_script_and_from_script() {
hint: If you meant to run a command from the `script-py` package, use the normalized package name instead to disambiguate, e.g., `uv tool run --from script-py other-script.py`
");
}
/// Test windows runnable types, namely console scripts and legacy setuptools scripts.
/// Console Scripts <https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#console-scripts>
/// Legacy Scripts <https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/#scripts>.
///
/// This tests for uv tool run of windows runnable types defined by [`WindowsRunnable`].
#[cfg(windows)]
#[test]
fn tool_run_windows_runnable_types() -> anyhow::Result<()> {
let context = TestContext::new("3.12").with_filtered_counts();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
let foo_dir = context.temp_dir.child("foo");
let foo_pyproject_toml = foo_dir.child("pyproject.toml");
// Use `script-files` which enables legacy scripts packaging.
foo_pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "1.0.0"
requires-python = ">=3.8"
dependencies = []
[project.scripts]
custom_pydoc = "foo.main:run"
[tool.setuptools]
script-files = [
"misc/custom_pydoc.bat",
"misc/custom_pydoc.cmd",
"misc/custom_pydoc.ps1"
]
[build-system]
requires = ["setuptools>=42"]
build-backend = "setuptools.build_meta"
"#
})?;
// Create the legacy scripts
let custom_pydoc_bat = foo_dir.child("misc").child("custom_pydoc.bat");
let custom_pydoc_cmd = foo_dir.child("misc").child("custom_pydoc.cmd");
let custom_pydoc_ps1 = foo_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")?;
// Create the foo module
let foo_project_src = foo_dir.child("src");
let foo_module = foo_project_src.child("foo");
let foo_main_py = foo_module.child("main.py");
foo_main_py.write_str(indoc! { r#"
import pydoc, sys
def run():
sys.argv[0] = "pydoc"
pydoc.cli()
__name__ == "__main__" and run()
"#
})?;
// Install `foo` tool.
context
.tool_install()
.arg(foo_dir.as_os_str())
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str())
.assert()
.success();
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("foo")
.arg("does_not_exist")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
success: false
exit_code: 1
----- stdout -----
The executable `does_not_exist` was not found.
The following executables are provided by `foo`:
- custom_pydoc.exe
- custom_pydoc.bat
- custom_pydoc.cmd
- custom_pydoc.ps1
Consider using `uv tool run --from foo <EXECUTABLE_NAME>` instead.
----- stderr -----
warning: An executable named `does_not_exist` is not provided by package `foo`.
"###);
// Test with explicit .bat extension
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("foo")
.arg("custom_pydoc.bat")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @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 -----
"###);
// Test with explicit .cmd extension
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("foo")
.arg("custom_pydoc.cmd")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @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 -----
"###);
// Test with explicit .ps1 extension
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("foo")
.arg("custom_pydoc.ps1")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @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 -----
"###);
// Test with explicit .exe extension
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("foo")
.arg("custom_pydoc")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @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 -----
"###);
// Test without explicit extension (.exe should be used)
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("foo")
.arg("custom_pydoc")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, bin_dir.as_os_str()), @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 -----
"###);
Ok(())
}

View File

@ -64,6 +64,27 @@ print([(k, v["title"]) for k, v in data.items()][:10])
The invocation `uv run example.py` would run _isolated_ from the project with only the given
dependencies listed.
## Legacy Windows Scripts
Support is provided for
[legacy setuptools scripts](https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/#scripts).
These types of scripts are additional files installed by setuptools in `.venv\Scripts`.
Currently only legacy scripts with the `.ps1`, `.cmd`, and `.bat` extensions are supported.
For example, below is an example running a Command Prompt script.
```console
$ uv run --with nuitka==2.6.7 -- nuitka.cmd --version
```
In addition, you don't need to specify the extension. `uv` will automatically look for files ending
in `.ps1`, `.cmd`, and `.bat` in that order of execution on your behalf.
```console
$ uv run --with nuitka==2.6.7 -- nuitka --version
```
## Signal handling
uv does not cede control of the process to the spawned command in order to provide better error

View File

@ -264,6 +264,27 @@ $ uv tool upgrade --python 3.10 ruff
For more details on requesting Python versions, see the
[Python version](../concepts/python-versions.md#requesting-a-version) concept page..
## Legacy Windows Scripts
Tools also support running
[legacy setuptools scripts](https://packaging.python.org/en/latest/guides/distributing-packages-using-setuptools/#scripts).
These scripts are available via `$(uv tool dir)\<tool-name>\Scripts` when installed.
Currently only legacy scripts with the `.ps1`, `.cmd`, and `.bat` extensions are supported.
For example, below is an example running a Command Prompt script.
```console
$ uv tool run --from nuitka==2.6.7 nuitka.cmd --version
```
In addition, you don't need to specify the extension. `uvx` will automatically look for files ending
in `.ps1`, `.cmd`, and `.bat` in that order of execution on your behalf.
```console
$ uv tool run --from nuitka==2.6.7 nuitka --version
```
## Next steps
To learn more about managing tools with uv, see the [Tools concept](../concepts/tools.md) page and