diff --git a/crates/uv-install-wheel/src/wheel.rs b/crates/uv-install-wheel/src/wheel.rs index afe8ba05d5b..68a19ff641e 100644 --- a/crates/uv-install-wheel/src/wheel.rs +++ b/crates/uv-install-wheel/src/wheel.rs @@ -161,6 +161,8 @@ fn get_script_executable(python_executable: &Path, is_gui: bool) -> PathBuf { } const RESERVED_SCRIPT_NAMES_ERROR: &[&str; 3] = &["python", "pythonw", "python3"]; +const RESERVED_VERSIONED_SCRIPT_NAME_PREFIX_ERROR: &str = "python3."; +const RESERVED_FREE_THREADED_SCRIPT_NAME_PREFIXES_ERROR: &[&str; 2] = &["python3.", "pythonw3."]; const RESERVED_SCRIPT_NAMES_WARN: &[&str; 2] = &["activate", "activate_this.py"]; /// A form of [`Script`] guaranteed by [`ValidatedScript::try_from_script`] to be constrained to @@ -193,25 +195,32 @@ impl<'script> ValidatedScript<'script> { ); } - if RESERVED_SCRIPT_NAMES_ERROR.contains(&name.as_str()) - || name - .strip_prefix("python3.") - .is_some_and(|suffix| suffix.parse::().is_ok()) + // Reserve CPython launcher basenames emitted by `uv venv` across supported platforms. + // Apply the Windows launcher normalization before checking so wheel validity is portable. + // FIXME: What are the in-reality rules here for name normalization? + let normalized_name = name.strip_suffix(".py").unwrap_or(name.as_str()); + if RESERVED_SCRIPT_NAMES_ERROR.contains(&normalized_name) + || normalized_name + .strip_prefix(RESERVED_VERSIONED_SCRIPT_NAME_PREFIX_ERROR) + .is_some_and(|minor| minor.parse::().is_ok()) + || RESERVED_FREE_THREADED_SCRIPT_NAME_PREFIXES_ERROR + .iter() + .any(|prefix| { + normalized_name + .strip_prefix(prefix) + .and_then(|minor| minor.strip_suffix('t')) + .is_some_and(|minor| minor.parse::().is_ok()) + }) { return Err(Error::ReservedScriptName { - reserved: name, + reserved: normalized_name.to_string(), declared: script.name.clone(), }); } let path = if cfg!(windows) { // On Windows we actually build an `.exe` wrapper. - let name = name - // FIXME: What are the in-reality rules here for names? - .strip_suffix(".py") - .unwrap_or(&name) - .to_string() - + std::env::consts::EXE_SUFFIX; + let name = normalized_name.to_string() + std::env::consts::EXE_SUFFIX; layout.scheme.scripts.join(name) } else { diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index c381c74cc23..fc4127442e5 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -13419,6 +13419,93 @@ fn reject_normalized_reserved_gui_wheel_entrypoint_name() -> Result<()> { Ok(()) } +#[test] +fn reject_free_threaded_python_wheel_entrypoint_name() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let repacked_wheel = + repacked_wheel_with_entrypoint(&context, "console_scripts", "python3.13t")?; + + uv_snapshot!(context.filters(), context.pip_install().arg(&repacked_wheel), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + error: Failed to install: foo-0.1.0-py3-none-any.whl (foo==0.1.0 (from file://[TEMP_DIR]/foo-0.1.0-py3-none-any.whl)) + Caused by: Scripts must not use the reserved name `python3.13t`, got: `python3.13t` + " + ); + + Ok(()) +} + +#[test] +fn reject_windowed_free_threaded_python_wheel_entrypoint_name() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let repacked_wheel = + repacked_wheel_with_entrypoint(&context, "console_scripts", "pythonw3.13t")?; + + uv_snapshot!(context.filters(), context.pip_install().arg(&repacked_wheel), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + error: Failed to install: foo-0.1.0-py3-none-any.whl (foo==0.1.0 (from file://[TEMP_DIR]/foo-0.1.0-py3-none-any.whl)) + Caused by: Scripts must not use the reserved name `pythonw3.13t`, got: `pythonw3.13t` + " + ); + + Ok(()) +} + +#[test] +fn reject_windows_rewritten_python_wheel_entrypoint_name() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let repacked_wheel = repacked_wheel_with_entrypoint(&context, "console_scripts", "python.py")?; + + uv_snapshot!(context.filters(), context.pip_install().arg(&repacked_wheel), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + error: Failed to install: foo-0.1.0-py3-none-any.whl (foo==0.1.0 (from file://[TEMP_DIR]/foo-0.1.0-py3-none-any.whl)) + Caused by: Scripts must not use the reserved name `python`, got: `python.py` + " + ); + + Ok(()) +} + +#[test] +fn reject_windows_rewritten_free_threaded_python_wheel_entrypoint_name() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let repacked_wheel = + repacked_wheel_with_entrypoint(&context, "console_scripts", "pythonw3.13t.py")?; + + uv_snapshot!(context.filters(), context.pip_install().arg(&repacked_wheel), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Resolved 1 package in [TIME] + Prepared 1 package in [TIME] + error: Failed to install: foo-0.1.0-py3-none-any.whl (foo==0.1.0 (from file://[TEMP_DIR]/foo-0.1.0-py3-none-any.whl)) + Caused by: Scripts must not use the reserved name `pythonw3.13t`, got: `pythonw3.13t.py` + " + ); + + Ok(()) +} + #[test] fn warn_normalized_activation_wheel_entrypoint_name() -> Result<()> { let context = uv_test::test_context!("3.12");