uv run supports python zipapp (#7289)

## Summary

`python` supports running a zipfile containing a `__main__.py` file, for
example `python ./pre-commit-3.8.0.pyz`.

See https://docs.python.org/3/using/cmdline.html#interface-options:

> <script> Execute the Python code contained in script, which must be a
filesystem path (absolute or relative) referring to either a Python
file, a directory containing a __main__.py file, or a zipfile containing
a __main__.py file.

and https://docs.python.org/3/library/zipapp.html.

Similar to #7281, this PR allows `uv run ./pre-commit-3.8.0.pyz` to
work.

## Test Plan

```console
$ curl -O https://github.com/pre-commit/pre-commit/releases/download/v3.8.0/pre-commit-3.8.0.pyz
$ cargo run -- run ./pre-commit-3.8.0.pyz
```
This commit is contained in:
Jo 2024-09-12 03:34:29 +08:00 committed by GitHub
parent b05217e624
commit bb0fb8e9bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 75 additions and 9 deletions

View file

@ -79,6 +79,7 @@ tracing-tree = { workspace = true }
unicode-width = { workspace = true }
url = { workspace = true }
which = { workspace = true }
zip = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
mimalloc = { version = "0.1.39" }

View file

@ -780,6 +780,9 @@ pub(crate) enum RunCommand {
PythonGuiScript(PathBuf, Vec<OsString>),
/// Execute a Python package containing a `__main__.py` file.
PythonPackage(PathBuf, Vec<OsString>),
/// Execute a Python [zipapp].
/// [zipapp]: <https://docs.python.org/3/library/zipapp.html>
PythonZipapp(PathBuf, Vec<OsString>),
/// Execute a `python` script provided via `stdin`.
PythonStdin(Vec<u8>),
/// Execute an external command.
@ -793,10 +796,11 @@ impl RunCommand {
fn display_executable(&self) -> Cow<'_, str> {
match self {
Self::Python(_) => Cow::Borrowed("python"),
Self::PythonScript(_, _) | Self::PythonPackage(_, _) | Self::Empty => {
Cow::Borrowed("python")
}
Self::PythonGuiScript(_, _) => Cow::Borrowed("pythonw"),
Self::PythonScript(..)
| Self::PythonPackage(..)
| Self::PythonZipapp(..)
| Self::Empty => Cow::Borrowed("python"),
Self::PythonGuiScript(..) => Cow::Borrowed("pythonw"),
Self::PythonStdin(_) => Cow::Borrowed("python -c"),
Self::External(executable, _) => executable.to_string_lossy(),
}
@ -810,7 +814,9 @@ impl RunCommand {
process.args(args);
process
}
Self::PythonScript(target, args) | Self::PythonPackage(target, args) => {
Self::PythonScript(target, args)
| Self::PythonPackage(target, args)
| Self::PythonZipapp(target, args) => {
let mut process = Command::new(interpreter.sys_executable());
process.arg(target);
process.args(args);
@ -873,7 +879,9 @@ impl std::fmt::Display for RunCommand {
}
Ok(())
}
Self::PythonScript(target, args) | Self::PythonPackage(target, args) => {
Self::PythonScript(target, args)
| Self::PythonPackage(target, args)
| Self::PythonZipapp(target, args) => {
write!(f, "python {}", target.display())?;
for arg in args {
write!(f, " {}", arg.to_string_lossy())?;
@ -917,6 +925,10 @@ impl TryFrom<&ExternalCommand> for RunCommand {
};
let target_path = PathBuf::from(&target);
let metadata = target_path.metadata();
let is_file = metadata.as_ref().map_or(false, std::fs::Metadata::is_file);
let is_dir = metadata.as_ref().map_or(false, std::fs::Metadata::is_dir);
if target.eq_ignore_ascii_case("-") {
let mut buf = Vec::with_capacity(1024);
std::io::stdin().read_to_end(&mut buf)?;
@ -926,18 +938,20 @@ impl TryFrom<&ExternalCommand> for RunCommand {
} else if target_path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("py") || ext.eq_ignore_ascii_case("pyc"))
&& target_path.exists()
&& is_file
{
Ok(Self::PythonScript(target_path, args.to_vec()))
} else if cfg!(windows)
&& target_path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("pyw"))
&& target_path.exists()
&& is_file
{
Ok(Self::PythonGuiScript(target_path, args.to_vec()))
} else if target_path.is_dir() && target_path.join("__main__.py").exists() {
} else if is_dir && target_path.join("__main__.py").is_file() {
Ok(Self::PythonPackage(target_path, args.to_vec()))
} else if is_file && is_python_zipapp(&target_path) {
Ok(Self::PythonZipapp(target_path, args.to_vec()))
} else {
Ok(Self::External(
target.clone(),
@ -946,3 +960,15 @@ impl TryFrom<&ExternalCommand> for RunCommand {
}
}
}
/// Returns `true` if the target is a ZIP archive containing a `__main__.py` file.
fn is_python_zipapp(target: &Path) -> bool {
if let Ok(file) = fs_err::File::open(target) {
if let Ok(mut archive) = zip::ZipArchive::new(file) {
return archive
.by_name("__main__.py")
.map_or(false, |f| f.is_file());
}
}
false
}

View file

@ -1656,6 +1656,45 @@ fn run_package() -> Result<()> {
Ok(())
}
#[test]
fn run_zipapp() -> Result<()> {
let context = TestContext::new("3.12");
// Create a zipapp.
let child = context.temp_dir.child("app");
child.create_dir_all()?;
let main_script = child.child("__main__.py");
main_script.write_str(indoc! { r#"
print("Hello, world!")
"#
})?;
let zipapp = context.temp_dir.child("app.pyz");
let status = context
.run()
.arg("python")
.arg("-m")
.arg("zipapp")
.arg(child.as_ref())
.arg("--output")
.arg(zipapp.as_ref())
.status()?;
assert!(status.success());
// Run the zipapp.
uv_snapshot!(context.filters(), context.run().arg(zipapp.as_ref()), @r###"
success: true
exit_code: 0
----- stdout -----
Hello, world!
----- stderr -----
"###);
Ok(())
}
/// When the `pyproject.toml` file is invalid.
#[test]
fn run_project_toml_error() -> Result<()> {