diff --git a/crates/uv-shell/src/lib.rs b/crates/uv-shell/src/lib.rs index a8a0a96ab..83f3f62e8 100644 --- a/crates/uv-shell/src/lib.rs +++ b/crates/uv-shell/src/lib.rs @@ -1,3 +1,4 @@ +pub mod runnable; mod shlex; pub mod windows; diff --git a/crates/uv-shell/src/runnable.rs b/crates/uv-shell/src/runnable.rs new file mode 100644 index 000000000..37d5027ef --- /dev/null +++ b/crates/uv-shell/src/runnable.rs @@ -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 { + 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)) + } +} diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 7b5c38516..9e2460630 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::{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 { - 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) }; diff --git a/crates/uv/src/commands/tool/run.rs b/crates/uv/src/commands/tool/run.rs index 720321bbf..46f0d22d4 100644 --- a/crates/uv/src/commands/tool/run.rs +++ b/crates/uv/src/commands/tool/run.rs @@ -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. diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 9e458ce16..c49e070c0 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -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 diff --git a/crates/uv/tests/it/tool_run.rs b/crates/uv/tests/it/tool_run.rs index 0af82e07d..b4fb2dc8a 100644 --- a/crates/uv/tests/it/tool_run.rs +++ b/crates/uv/tests/it/tool_run.rs @@ -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 +/// Legacy 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 ` 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 ... + Show text documentation on something. 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 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 + Search for a keyword in the synopsis lines of all available modules. + + pydoc -n + Start an HTTP server with the given hostname (default: localhost). + + pydoc -p + 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 ... + Write out the HTML documentation for a module to a file in the current + directory. If 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 ... + Show text documentation on something. 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 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 + Search for a keyword in the synopsis lines of all available modules. + + pydoc -n + Start an HTTP server with the given hostname (default: localhost). + + pydoc -p + 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 ... + Write out the HTML documentation for a module to a file in the current + directory. If 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 ... + Show text documentation on something. 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 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 + Search for a keyword in the synopsis lines of all available modules. + + pydoc -n + Start an HTTP server with the given hostname (default: localhost). + + pydoc -p + 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 ... + Write out the HTML documentation for a module to a file in the current + directory. If 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 ... + Show text documentation on something. 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 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 + Search for a keyword in the synopsis lines of all available modules. + + pydoc -n + Start an HTTP server with the given hostname (default: localhost). + + pydoc -p + 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 ... + Write out the HTML documentation for a module to a file in the current + directory. If 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 ... + Show text documentation on something. 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 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 + Search for a keyword in the synopsis lines of all available modules. + + pydoc -n + Start an HTTP server with the given hostname (default: localhost). + + pydoc -p + 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 ... + Write out the HTML documentation for a module to a file in the current + directory. If contains a '\', it is treated as a filename; if + it names a directory, documentation is written for all the contents. + + + ----- stderr ----- + "###); + + Ok(()) +} diff --git a/docs/concepts/projects/run.md b/docs/concepts/projects/run.md index 73cdf7bf4..e66079f12 100644 --- a/docs/concepts/projects/run.md +++ b/docs/concepts/projects/run.md @@ -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 diff --git a/docs/guides/tools.md b/docs/guides/tools.md index a72ef1979..8548e0aa0 100644 --- a/docs/guides/tools.md +++ b/docs/guides/tools.md @@ -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)\\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