Prefer running executables in the environment with <name> over <name>/__main__.py (#11431)

Closes https://github.com/astral-sh/uv/issues/11423
Closes https://github.com/astral-sh/uv/issues/9167
Closes https://github.com/astral-sh/uv/pull/9722


c23fc4024e
demonstrates the behavior.
This commit is contained in:
Zanie Blue 2025-02-12 12:08:55 -06:00 committed by GitHub
parent 070120e1c2
commit 3f6a7f9879
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 122 additions and 9 deletions

View file

@ -1107,7 +1107,8 @@ pub(crate) enum RunCommand {
/// Execute a `pythonw` GUI script.
PythonGuiScript(PathBuf, Vec<OsString>),
/// Execute a Python package containing a `__main__.py` file.
PythonPackage(PathBuf, Vec<OsString>),
/// If an entrypoint with the target name is installed in the environment, it is preferred.
PythonPackage(OsString, PathBuf, Vec<OsString>),
/// Execute a Python [zipapp].
/// [zipapp]: <https://docs.python.org/3/library/zipapp.html>
PythonZipapp(PathBuf, Vec<OsString>),
@ -1129,10 +1130,12 @@ impl RunCommand {
match self {
Self::Python(_)
| Self::PythonScript(..)
| Self::PythonPackage(..)
| Self::PythonZipapp(..)
| Self::PythonRemote(..)
| Self::Empty => Cow::Borrowed("python"),
// N.B. We can't know if we'll invoke `<target>` or `python <target>` without checking
// the available scripts in the interpreter — we could improve this message
Self::PythonPackage(target, ..) => target.to_string_lossy(),
Self::PythonModule(..) => Cow::Borrowed("python -m"),
Self::PythonGuiScript(..) => {
if cfg!(windows) {
@ -1161,9 +1164,24 @@ impl RunCommand {
process.args(args);
process
}
Self::PythonScript(target, args)
| Self::PythonPackage(target, args)
| Self::PythonZipapp(target, args) => {
Self::PythonPackage(target, path, args) => {
let name = PathBuf::from(target).with_extension(std::env::consts::EXE_EXTENSION);
let entrypoint = interpreter.scripts().join(name);
// If the target is an installed, executable script — prefer that
if uv_fs::which::is_executable(&entrypoint) {
let mut process = Command::new(entrypoint);
process.args(args);
process
// Otherwise, invoke `python <module>`
} else {
let mut process = Command::new(interpreter.sys_executable());
process.arg(path);
process.args(args);
process
}
}
Self::PythonScript(target, args) | Self::PythonZipapp(target, args) => {
let mut process = Command::new(interpreter.sys_executable());
process.arg(target);
process.args(args);
@ -1272,9 +1290,14 @@ impl std::fmt::Display for RunCommand {
}
Ok(())
}
Self::PythonScript(target, args)
| Self::PythonPackage(target, args)
| Self::PythonZipapp(target, args) => {
Self::PythonPackage(target, _path, args) => {
write!(f, "{}", target.to_string_lossy())?;
for arg in args {
write!(f, " {}", arg.to_string_lossy())?;
}
Ok(())
}
Self::PythonScript(target, args) | Self::PythonZipapp(target, args) => {
write!(f, "python {}", target.display())?;
for arg in args {
write!(f, " {}", arg.to_string_lossy())?;
@ -1416,7 +1439,11 @@ impl RunCommand {
{
Ok(Self::PythonGuiScript(target_path, args.to_vec()))
} else if is_dir && target_path.join("__main__.py").is_file() {
Ok(Self::PythonPackage(target_path, args.to_vec()))
Ok(Self::PythonPackage(
target.clone(),
target_path,
args.to_vec(),
))
} else if is_file && is_python_zipapp(&target_path) {
Ok(Self::PythonZipapp(target_path, args.to_vec()))
} else {

View file

@ -3151,6 +3151,92 @@ fn run_script_without_build_system() -> Result<()> {
Ok(())
}
#[test]
fn run_script_module_conflict() -> Result<()> {
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
[project]
name = "foo"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
[project.scripts]
foo = "foo:app"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
"#
})?;
let init = context.temp_dir.child("src/foo/__init__.py");
init.write_str(indoc! { r#"
def app():
print("Hello from `__init__`")
"#
})?;
uv_snapshot!(context.filters(), context.run().arg("foo"), @r###"
success: true
exit_code: 0
----- stdout -----
Hello from `__init__`
----- stderr -----
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ foo==0.1.0 (from file://[TEMP_DIR]/)
"###);
// Creating `__main__` should not change the behavior, the entrypoint should take precedence
let main = context.temp_dir.child("src/foo/__main__.py");
main.write_str(indoc! { r#"
print("Hello from `__main__`")
"#
})?;
uv_snapshot!(context.filters(), context.run().arg("foo"), @r###"
success: true
exit_code: 0
----- stdout -----
Hello from `__init__`
----- stderr -----
Resolved 1 package in [TIME]
Audited 1 package in [TIME]
"###);
// Even if the working directory is `src`
uv_snapshot!(context.filters(), context.run().arg("--directory").arg("src").arg("foo"), @r###"
success: true
exit_code: 0
----- stdout -----
Hello from `__init__`
----- stderr -----
Resolved 1 package in [TIME]
Audited 1 package in [TIME]
"###);
// Unless the user opts-in to module running with `-m`
uv_snapshot!(context.filters(), context.run().arg("-m").arg("foo"), @r###"
success: true
exit_code: 0
----- stdout -----
Hello from `__main__`
----- stderr -----
Resolved 1 package in [TIME]
Audited 1 package in [TIME]
"###);
Ok(())
}
#[test]
fn run_script_explicit() -> Result<()> {
let context = TestContext::new("3.12");