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; mod shlex;
pub mod windows; 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::borrow::Cow;
use std::env::VarError; use std::env::VarError;
use std::ffi::{OsStr, OsString}; use std::ffi::OsString;
use std::fmt::Write; use std::fmt::Write;
use std::io::Read; use std::io::Read;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -33,6 +33,7 @@ use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_resolver::Lock; use uv_resolver::Lock;
use uv_scripts::Pep723Item; use uv_scripts::Pep723Item;
use uv_settings::PythonInstallMirrors; use uv_settings::PythonInstallMirrors;
use uv_shell::runnable::WindowsRunnable;
use uv_static::EnvVars; use uv_static::EnvVars;
use uv_warnings::warn_user; use uv_warnings::warn_user;
use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceCache, WorkspaceError}; 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)] #[derive(Debug)]
pub(crate) enum RunCommand { pub(crate) enum RunCommand {
/// Execute `python`. /// 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`]. /// Convert a [`RunCommand`] into a [`Command`].
fn as_command(&self, interpreter: &Interpreter) -> Command { fn as_command(&self, interpreter: &Interpreter) -> Command {
match self { match self {
@ -1386,7 +1304,7 @@ impl RunCommand {
} }
Self::External(executable, args) => { Self::External(executable, args) => {
let mut process = if cfg!(windows) { let mut process = if cfg!(windows) {
Self::for_windows_script(interpreter, executable) WindowsRunnable::from_script_path(interpreter.scripts(), executable).into()
} else { } else {
Command::new(executable) Command::new(executable)
}; };

View File

@ -34,6 +34,7 @@ use uv_python::{
}; };
use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_requirements::{RequirementsSource, RequirementsSpecification};
use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions}; use uv_settings::{PythonInstallMirrors, ResolverInstallerOptions, ToolOptions};
use uv_shell::runnable::WindowsRunnable;
use uv_static::EnvVars; use uv_static::EnvVars;
use uv_tool::{entrypoint_paths, InstalledTools}; use uv_tool::{entrypoint_paths, InstalledTools};
use uv_warnings::warn_user; use uv_warnings::warn_user;
@ -260,7 +261,12 @@ pub(crate) async fn run(
let executable = from.executable(); let executable = from.executable();
// Construct the command // 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); process.args(args);
// Construct the `PATH` environment variable. // Construct the `PATH` environment variable.

View File

@ -4560,7 +4560,7 @@ fn run_windows_legacy_scripts() -> Result<()> {
Audited 1 package in [TIME] 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###" uv_snapshot!(context.filters(), context.run().arg("custom_pydoc"), @r###"
success: true success: true
exit_code: 0 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` 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 The invocation `uv run example.py` would run _isolated_ from the project with only the given
dependencies listed. 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 ## Signal handling
uv does not cede control of the process to the spawned command in order to provide better error 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 For more details on requesting Python versions, see the
[Python version](../concepts/python-versions.md#requesting-a-version) concept page.. [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 ## Next steps
To learn more about managing tools with uv, see the [Tools concept](../concepts/tools.md) page and To learn more about managing tools with uv, see the [Tools concept](../concepts/tools.md) page and