Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 89 additions & 2 deletions crates/uv/src/commands/project/run.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand Down
231 changes: 231 additions & 0 deletions crates/uv/tests/it/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}