diff --git a/crates/uv-install-wheel/src/lib.rs b/crates/uv-install-wheel/src/lib.rs index bc19a7c63..153398042 100644 --- a/crates/uv-install-wheel/src/lib.rs +++ b/crates/uv-install-wheel/src/lib.rs @@ -82,4 +82,6 @@ pub enum Error { InvalidEggLink(PathBuf), #[error(transparent)] LauncherError(#[from] uv_trampoline_builder::Error), + #[error("Scripts must not use the reserved name {0}")] + ReservedScriptName(String), } diff --git a/crates/uv-install-wheel/src/wheel.rs b/crates/uv-install-wheel/src/wheel.rs index ebc8888b6..1ba81276a 100644 --- a/crates/uv-install-wheel/src/wheel.rs +++ b/crates/uv-install-wheel/src/wheel.rs @@ -18,6 +18,7 @@ use uv_normalize::PackageName; use uv_pypi_types::DirectUrl; use uv_shell::escape_posix_for_single_quotes; use uv_trampoline_builder::windows_script_launcher; +use uv_warnings::warn_user_once; use crate::record::RecordEntry; use crate::script::{scripts_from_ini, Script}; @@ -186,6 +187,25 @@ pub(crate) fn write_script_entrypoints( is_gui: bool, ) -> Result<(), Error> { for entrypoint in entrypoints { + let warn_names = ["activate", "activate_this.py"]; + if warn_names.contains(&entrypoint.name.as_str()) + || entrypoint.name.starts_with("activate.") + { + warn_user_once!( + "The script name `{}` is reserved for virtual environment activation scripts.", + entrypoint.name + ); + } + let reserved_names = ["python", "pythonw", "python3"]; + if reserved_names.contains(&entrypoint.name.as_str()) + || entrypoint + .name + .strip_prefix("python3.") + .is_some_and(|suffix| suffix.parse::().is_ok()) + { + return Err(Error::ReservedScriptName(entrypoint.name.clone())); + } + let entrypoint_absolute = entrypoint_path(entrypoint, layout); let entrypoint_relative = pathdiff::diff_paths(&entrypoint_absolute, site_packages) diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index e4709c84d..1efee1286 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -11161,3 +11161,78 @@ async fn bogus_redirect() -> Result<()> { Ok(()) } + +#[test] +fn reserved_script_name() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [project.scripts] + "activate.bash" = "project:activate" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "#, + )?; + + context + .temp_dir + .child("src") + .child("project") + .child("__init__.py") + .touch()?; + + uv_snapshot!(context.filters(), context.pip_install().arg("."), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + warning: The script name `activate.bash` is reserved for virtual environment activation scripts. + Installed 1 package in [TIME] + + project==0.1.0 (from file://[TEMP_DIR]/) + " + ); + + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + + [project.scripts] + "python" = "project:python" + + [build-system] + requires = ["hatchling"] + build-backend = "hatchling.build" + "#, + )?; + + uv_snapshot!(context.filters(), context.pip_install().arg("."), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + error: Failed to install: project-0.1.0-py3-none-any.whl (project==0.1.0 (from file://[TEMP_DIR]/)) + Caused by: Scripts must not use the reserved name python + " + ); + + Ok(()) +}