Skip to content

Commit 6186228

Browse files
committed
uv run supports python zipapp
1 parent 38c7c5f commit 6186228

File tree

3 files changed

+79
-9
lines changed

3 files changed

+79
-9
lines changed

crates/uv/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ tracing-tree = { workspace = true }
7979
unicode-width = { workspace = true }
8080
url = { workspace = true }
8181
which = { workspace = true }
82+
zip = { workspace = true }
8283

8384
[target.'cfg(target_os = "windows")'.dependencies]
8485
mimalloc = { version = "0.1.39" }

crates/uv/src/commands/project/run.rs

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -779,6 +779,9 @@ pub(crate) enum RunCommand {
779779
PythonGuiScript(PathBuf, Vec<OsString>),
780780
/// Execute a Python package containing a `__main__.py` file.
781781
PythonPackage(PathBuf, Vec<OsString>),
782+
/// Execute a Python [zipapp].
783+
/// [zipapp]: https://docs.python.org/3/library/zipapp.html
784+
PythonZipapp(PathBuf, Vec<OsString>),
782785
/// Execute a `python` script provided via `stdin`.
783786
PythonStdin(Vec<u8>),
784787
/// Execute an external command.
@@ -792,10 +795,11 @@ impl RunCommand {
792795
fn display_executable(&self) -> Cow<'_, str> {
793796
match self {
794797
Self::Python(_) => Cow::Borrowed("python"),
795-
Self::PythonScript(_, _) | Self::PythonPackage(_, _) | Self::Empty => {
796-
Cow::Borrowed("python")
797-
}
798-
Self::PythonGuiScript(_, _) => Cow::Borrowed("pythonw"),
798+
Self::PythonScript(..)
799+
| Self::PythonPackage(..)
800+
| Self::PythonZipapp(..)
801+
| Self::Empty => Cow::Borrowed("python"),
802+
Self::PythonGuiScript(..) => Cow::Borrowed("pythonw"),
799803
Self::PythonStdin(_) => Cow::Borrowed("python -c"),
800804
Self::External(executable, _) => executable.to_string_lossy(),
801805
}
@@ -809,7 +813,9 @@ impl RunCommand {
809813
process.args(args);
810814
process
811815
}
812-
Self::PythonScript(target, args) | Self::PythonPackage(target, args) => {
816+
Self::PythonScript(target, args)
817+
| Self::PythonPackage(target, args)
818+
| Self::PythonZipapp(target, args) => {
813819
let mut process = Command::new(interpreter.sys_executable());
814820
process.arg(target);
815821
process.args(args);
@@ -872,7 +878,9 @@ impl std::fmt::Display for RunCommand {
872878
}
873879
Ok(())
874880
}
875-
Self::PythonScript(target, args) | Self::PythonPackage(target, args) => {
881+
Self::PythonScript(target, args)
882+
| Self::PythonPackage(target, args)
883+
| Self::PythonZipapp(target, args) => {
876884
write!(f, "python {}", target.display())?;
877885
for arg in args {
878886
write!(f, " {}", arg.to_string_lossy())?;
@@ -916,6 +924,14 @@ impl TryFrom<&ExternalCommand> for RunCommand {
916924
};
917925

918926
let target_path = PathBuf::from(&target);
927+
let metadata = target_path.metadata();
928+
let is_file = metadata
929+
.as_ref()
930+
.map_or(false, |metadata| metadata.is_file());
931+
let is_dir = metadata
932+
.as_ref()
933+
.map_or(false, |metadata| metadata.is_dir());
934+
919935
if target.eq_ignore_ascii_case("-") {
920936
let mut buf = Vec::with_capacity(1024);
921937
std::io::stdin().read_to_end(&mut buf)?;
@@ -925,18 +941,20 @@ impl TryFrom<&ExternalCommand> for RunCommand {
925941
} else if target_path
926942
.extension()
927943
.is_some_and(|ext| ext.eq_ignore_ascii_case("py") || ext.eq_ignore_ascii_case("pyc"))
928-
&& target_path.exists()
944+
&& is_file
929945
{
930946
Ok(Self::PythonScript(target_path, args.to_vec()))
931947
} else if cfg!(windows)
932948
&& target_path
933949
.extension()
934950
.is_some_and(|ext| ext.eq_ignore_ascii_case("pyw"))
935-
&& target_path.exists()
951+
&& is_file
936952
{
937953
Ok(Self::PythonGuiScript(target_path, args.to_vec()))
938-
} else if target_path.is_dir() && target_path.join("__main__.py").exists() {
954+
} else if is_dir && target_path.join("__main__.py").is_file() {
939955
Ok(Self::PythonPackage(target_path, args.to_vec()))
956+
} else if is_file && is_python_zipapp(&target_path) {
957+
Ok(Self::PythonZipapp(target_path, args.to_vec()))
940958
} else {
941959
Ok(Self::External(
942960
target.clone(),
@@ -945,3 +963,15 @@ impl TryFrom<&ExternalCommand> for RunCommand {
945963
}
946964
}
947965
}
966+
967+
/// Returns `true` if the target is a ZIP archive containing a `__main__.py` file.
968+
fn is_python_zipapp(target: &Path) -> bool {
969+
if let Ok(file) = std::fs::File::open(target) {
970+
if let Ok(mut archive) = zip::ZipArchive::new(file) {
971+
return archive
972+
.by_name("__main__.py")
973+
.map_or(false, |f| f.is_file());
974+
}
975+
}
976+
false
977+
}

crates/uv/tests/run.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1656,6 +1656,45 @@ fn run_package() -> Result<()> {
16561656
Ok(())
16571657
}
16581658

1659+
#[test]
1660+
fn run_zipapp() -> Result<()> {
1661+
let context = TestContext::new("3.12");
1662+
1663+
// Create a zipapp.
1664+
let child = context.temp_dir.child("app");
1665+
child.create_dir_all()?;
1666+
1667+
let main_script = child.child("__main__.py");
1668+
main_script.write_str(indoc! { r#"
1669+
print("Hello, world!")
1670+
"#
1671+
})?;
1672+
1673+
let zipapp = context.temp_dir.child("app.pyz");
1674+
let status = context
1675+
.run()
1676+
.arg("python")
1677+
.arg("-m")
1678+
.arg("zipapp")
1679+
.arg(child.as_ref())
1680+
.arg("--output")
1681+
.arg(zipapp.as_ref())
1682+
.status()?;
1683+
assert!(status.success());
1684+
1685+
// Run the zipapp.
1686+
uv_snapshot!(context.filters(), context.run().arg(zipapp.as_ref()), @r###"
1687+
success: true
1688+
exit_code: 0
1689+
----- stdout -----
1690+
Hello, world!
1691+
1692+
----- stderr -----
1693+
"###);
1694+
1695+
Ok(())
1696+
}
1697+
16591698
/// When the `pyproject.toml` file is invalid.
16601699
#[test]
16611700
fn run_project_toml_error() -> Result<()> {

0 commit comments

Comments
 (0)