mirror of https://github.com/astral-sh/uv
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:
parent
28ff80b639
commit
0fb5291239
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue