From 8e1a432198688d246a23b3ee23acb1f73693a99c Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sat, 15 Mar 2025 22:13:57 +0900 Subject: [PATCH 01/14] feat: add function for checking if command is python executable --- crates/uv/src/commands/project/run.rs | 82 ++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 0e7448138..5a8720df1 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -4,6 +4,7 @@ use std::ffi::OsString; use std::fmt::Write; use std::io::Read; use std::path::{Path, PathBuf}; +use std::str::FromStr; use anyhow::{anyhow, bail, Context}; use futures::StreamExt; @@ -26,9 +27,7 @@ use uv_fs::{PythonExt, Simplified}; use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::{DefaultGroups, PackageName}; use uv_python::{ - EnvironmentPreference, Interpreter, PyVenvConfiguration, PythonDownloads, PythonEnvironment, - PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile, - VersionFileDiscoveryOptions, + EnvironmentPreference, Interpreter, PyVenvConfiguration, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonVersion, PythonVersionFile, VersionFileDiscoveryOptions }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_resolver::Lock; @@ -1091,6 +1090,9 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl process.env(EnvVars::VIRTUAL_ENV, interpreter.sys_prefix().as_os_str()); } + let is_executable = is_python_executable(command.display_executable().as_ref()); + println!("Is executable: {}, {}", command.display_executable(), is_executable); + // Spawn and wait for completion // Standard input, output, and error streams are all inherited // TODO(zanieb): Throw a nicer error message if the command is not found @@ -1540,3 +1542,77 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result { .parse::() .with_context(|| format!("invalid value for {}", EnvVars::UV_RUN_RECURSION_DEPTH)) } + + +/// Matches valid Python executable names: +/// - ✅ "python", "python3", "python3.9", "python4", "python3.10" +/// - ❌ "python39", "python3abc", "python3.12b3", "python3.13.3" +fn is_python_executable(executable_command: &str) -> bool { + executable_command + .strip_prefix("python") + .map_or(false, |version| version.len() == 0 || is_valid_python_version(version)) +} + +/// Checks if a version string is a valid Python major.minor version (without patch). +fn is_valid_python_version(version: &str) -> bool { + PythonVersion::from_str(version) + .map_or(false, + |ver| + ver.patch().is_none() && + ver.is_stable() && + // Should not contain post info. E.g. "3.12b3" + !ver.is_post() + ) +}#[cfg(test)] +mod tests { + use super::{is_python_executable, is_valid_python_version}; + + /// Helper function for asserting test cases. + /// - If `expected_result` is `true`, it expects the function to return `true` (valid cases). + /// - If `expected_result` is `false`, it expects the function to return `false` (invalid cases). + fn assert_cases bool>(cases: &[&str], func: F, test_name: &str, expected_result: bool) { + for &case in cases { + assert_eq!( + func(case), + expected_result, + "{}: Expected {} but failed on case: {}", + test_name, + expected_result, + case + ); + } + } + + #[test] + fn valid_is_python_executable() { + let valid_cases = [ + "python3", "python3.9", "python3.10", "python4", "python", + "python39", // Still a valid executable, although likely a typo + ]; + assert_cases(&valid_cases, is_python_executable, "valid_is_python_executable", true); + } + + #[test] + fn invalid_is_python_executable() { + let invalid_cases = [ + "python-foo", "python3abc", "python3.12b3", "python3.13.3", + "pyth0n3", "", "Python3.9", "python.3.9" + ]; + assert_cases(&invalid_cases, is_python_executable, "invalid_is_python_executable", false); + } + + #[test] + fn valid_python_versions() { + let valid_cases = ["3", "3.9", "4", "3.10", "49"]; + assert_cases(&valid_cases, is_valid_python_version, "valid_python_versions", true); + } + + #[test] + fn invalid_python_versions() { + let invalid_cases = [ + "3.9.1", "3.12b3", "3.12rc1", "3.12a1", + "3.12.post1", "3.12-foo", "3abc", "..", "" + ]; + assert_cases(&invalid_cases, is_valid_python_version, "invalid_python_versions", false); + } +} From 22f2e7ac72d2e9f510c7dae6468f1b98b26f01be Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sun, 16 Mar 2025 18:57:42 +0900 Subject: [PATCH 02/14] feat: Add meaningful message when attempting to invoke python using run --- crates/uv/src/commands/project/run.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 5a8720df1..a30bc68b4 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1088,16 +1088,34 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl // Ensure `VIRTUAL_ENV` is set. if interpreter.is_virtualenv() { process.env(EnvVars::VIRTUAL_ENV, interpreter.sys_prefix().as_os_str()); - } - - let is_executable = is_python_executable(command.display_executable().as_ref()); - println!("Is executable: {}, {}", command.display_executable(), is_executable); + }; // Spawn and wait for completion // Standard input, output, and error streams are all inherited // TODO(zanieb): Throw a nicer error message if the command is not found let handle = process .spawn() + .map_err(|err| { + let executable: Cow<'_, str> = command.display_executable(); + // Special case for providing meaningful error message when users + // attempt to invoke python. E.g. "python3.11". + // Will not work if patch version is provided. I.E. "python3.11.9" + if err.kind() == std::io::ErrorKind::NotFound && is_python_executable(&executable) { + // Get version from python command string + // e.g. python3.12 -> "3.12" or "" if python version not specified. + let version_part = executable.strip_prefix("python").unwrap_or(""); + anyhow!( + "`{}` not available in the current environment, which uses python `{}`. + Did you mean `uv run -p {} python` or `uvx python@{}`?", + executable, + base_interpreter.python_version().only_release(), + version_part, + version_part + ) + } else { + err.into() + } + }) .with_context(|| format!("Failed to spawn: `{}`", command.display_executable()))?; run_to_completion(handle).await From 8ce5035d4197299d41951a25e12766bcf355d888 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sun, 16 Mar 2025 19:02:06 +0900 Subject: [PATCH 03/14] fix: enable message to show for patch versions --- crates/uv/src/commands/project/run.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index a30bc68b4..fcc51223d 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1563,20 +1563,19 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result { /// Matches valid Python executable names: -/// - ✅ "python", "python3", "python3.9", "python4", "python3.10" -/// - ❌ "python39", "python3abc", "python3.12b3", "python3.13.3" +/// - ✅ "python", "python3", "python3.9", "python4", "python3.10", "python3.13.3" +/// - ❌ "python39", "python3abc", "python3.12b3", "", "python-foo" fn is_python_executable(executable_command: &str) -> bool { executable_command .strip_prefix("python") .map_or(false, |version| version.len() == 0 || is_valid_python_version(version)) } -/// Checks if a version string is a valid Python major.minor version (without patch). +/// Checks if a version string is a valid Python major.minor.patch version. fn is_valid_python_version(version: &str) -> bool { PythonVersion::from_str(version) .map_or(false, |ver| - ver.patch().is_none() && ver.is_stable() && // Should not contain post info. E.g. "3.12b3" !ver.is_post() @@ -1605,6 +1604,7 @@ mod tests { fn valid_is_python_executable() { let valid_cases = [ "python3", "python3.9", "python3.10", "python4", "python", + "python3.11.3", "python39", // Still a valid executable, although likely a typo ]; assert_cases(&valid_cases, is_python_executable, "valid_is_python_executable", true); @@ -1613,7 +1613,7 @@ mod tests { #[test] fn invalid_is_python_executable() { let invalid_cases = [ - "python-foo", "python3abc", "python3.12b3", "python3.13.3", + "python-foo", "python3abc", "python3.12b3", "pyth0n3", "", "Python3.9", "python.3.9" ]; assert_cases(&invalid_cases, is_python_executable, "invalid_is_python_executable", false); @@ -1621,15 +1621,15 @@ mod tests { #[test] fn valid_python_versions() { - let valid_cases = ["3", "3.9", "4", "3.10", "49"]; + let valid_cases = ["3", "3.9", "4", "3.10", "49", "3.11.3"]; assert_cases(&valid_cases, is_valid_python_version, "valid_python_versions", true); } #[test] fn invalid_python_versions() { let invalid_cases = [ - "3.9.1", "3.12b3", "3.12rc1", "3.12a1", - "3.12.post1", "3.12-foo", "3abc", "..", "" + "3.12b3", "3.12rc1", "3.12a1", + "3.12.post1", "3.12.1-foo", "3abc", "..", "" ]; assert_cases(&invalid_cases, is_valid_python_version, "invalid_python_versions", false); } From a218c91983c606e189a7a859501b80715993b2f0 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sun, 16 Mar 2025 20:51:54 +0900 Subject: [PATCH 04/14] feat: add support for different message depending on if project is found --- crates/uv/src/commands/project/run.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index fcc51223d..c64bc004e 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -144,6 +144,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl let lock_state = UniversalState::default(); let sync_state = lock_state.fork(); let workspace_cache = WorkspaceCache::default(); + let mut project_found = true; // Read from the `.env` file, if necessary. if !no_env_file { @@ -805,6 +806,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl venv.into_interpreter() } else { debug!("No project found; searching for Python interpreter"); + project_found = false; let interpreter = { let client_builder = BaseClientBuilder::new() @@ -1104,13 +1106,28 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl // Get version from python command string // e.g. python3.12 -> "3.12" or "" if python version not specified. let version_part = executable.strip_prefix("python").unwrap_or(""); + let current_executable_python_version = base_interpreter.python_version().only_release(); + // Determine the environment type + let env_type = if project_found { "the project" } else { "the" }; + + // Construct the message dynamically + let message_suffix = if project_found { + format!( + "Did you mean to change the environment to Python {} with `uv run -p {} python`?", + version_part, version_part + ) + } else { + format!( + "Did you mean to search for a Python {} environment with `uv run -p {} python`?", + version_part, version_part + ) + }; anyhow!( - "`{}` not available in the current environment, which uses python `{}`. - Did you mean `uv run -p {} python` or `uvx python@{}`?", + "`{}` not available in {} environment, which uses python `{}`. {}", executable, - base_interpreter.python_version().only_release(), - version_part, - version_part + env_type, + current_executable_python_version, + message_suffix ) } else { err.into() From f4418172985707f2d894230809c0031817044f01 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sun, 16 Mar 2025 21:19:08 +0900 Subject: [PATCH 05/14] fix: run lint --- crates/uv/src/commands/project/run.rs | 97 ++++++++++++++++++--------- 1 file changed, 66 insertions(+), 31 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index c64bc004e..300b7d069 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -27,7 +27,9 @@ use uv_fs::{PythonExt, Simplified}; use uv_installer::{SatisfiesResult, SitePackages}; use uv_normalize::{DefaultGroups, PackageName}; use uv_python::{ - EnvironmentPreference, Interpreter, PyVenvConfiguration, PythonDownloads, PythonEnvironment, PythonInstallation, PythonPreference, PythonRequest, PythonVersion, PythonVersionFile, VersionFileDiscoveryOptions + EnvironmentPreference, Interpreter, PyVenvConfiguration, PythonDownloads, PythonEnvironment, + PythonInstallation, PythonPreference, PythonRequest, PythonVersion, PythonVersionFile, + VersionFileDiscoveryOptions, }; use uv_requirements::{RequirementsSource, RequirementsSpecification}; use uv_resolver::Lock; @@ -1091,14 +1093,14 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl if interpreter.is_virtualenv() { process.env(EnvVars::VIRTUAL_ENV, interpreter.sys_prefix().as_os_str()); }; - + // Spawn and wait for completion // Standard input, output, and error streams are all inherited // TODO(zanieb): Throw a nicer error message if the command is not found let handle = process .spawn() .map_err(|err| { - let executable: Cow<'_, str> = command.display_executable(); + let executable: Cow<'_, str> = command.display_executable(); // Special case for providing meaningful error message when users // attempt to invoke python. E.g. "python3.11". // Will not work if patch version is provided. I.E. "python3.11.9" @@ -1113,13 +1115,11 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl // Construct the message dynamically let message_suffix = if project_found { format!( - "Did you mean to change the environment to Python {} with `uv run -p {} python`?", - version_part, version_part + "Did you mean to change the environment to Python {version_part} with `uv run -p {version_part} python`?" ) } else { format!( - "Did you mean to search for a Python {} environment with `uv run -p {} python`?", - version_part, version_part + "Did you mean to search for a Python {version_part} environment with `uv run -p {version_part} python`?" ) }; anyhow!( @@ -1578,41 +1578,41 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result { .with_context(|| format!("invalid value for {}", EnvVars::UV_RUN_RECURSION_DEPTH)) } - /// Matches valid Python executable names: /// - ✅ "python", "python3", "python3.9", "python4", "python3.10", "python3.13.3" /// - ❌ "python39", "python3abc", "python3.12b3", "", "python-foo" fn is_python_executable(executable_command: &str) -> bool { executable_command .strip_prefix("python") - .map_or(false, |version| version.len() == 0 || is_valid_python_version(version)) + .is_some_and(|version| version.is_empty() || is_valid_python_version(version)) } /// Checks if a version string is a valid Python major.minor.patch version. fn is_valid_python_version(version: &str) -> bool { - PythonVersion::from_str(version) - .map_or(false, - |ver| - ver.is_stable() && + PythonVersion::from_str(version).is_ok_and(|ver| { + ver.is_stable() && // Should not contain post info. E.g. "3.12b3" !ver.is_post() - ) -}#[cfg(test)] + }) +} +#[cfg(test)] mod tests { use super::{is_python_executable, is_valid_python_version}; /// Helper function for asserting test cases. /// - If `expected_result` is `true`, it expects the function to return `true` (valid cases). /// - If `expected_result` is `false`, it expects the function to return `false` (invalid cases). - fn assert_cases bool>(cases: &[&str], func: F, test_name: &str, expected_result: bool) { + fn assert_cases bool>( + cases: &[&str], + func: F, + test_name: &str, + expected_result: bool, + ) { for &case in cases { + let result = func(case); assert_eq!( - func(case), - expected_result, - "{}: Expected {} but failed on case: {}", - test_name, - expected_result, - case + result, expected_result, + "{test_name}: Expected `{expected_result}`, but got `{result}` for case `{case}`" ); } } @@ -1620,34 +1620,69 @@ mod tests { #[test] fn valid_is_python_executable() { let valid_cases = [ - "python3", "python3.9", "python3.10", "python4", "python", + "python3", + "python3.9", + "python3.10", + "python4", + "python", "python3.11.3", "python39", // Still a valid executable, although likely a typo ]; - assert_cases(&valid_cases, is_python_executable, "valid_is_python_executable", true); + assert_cases( + &valid_cases, + is_python_executable, + "valid_is_python_executable", + true, + ); } #[test] fn invalid_is_python_executable() { let invalid_cases = [ - "python-foo", "python3abc", "python3.12b3", - "pyth0n3", "", "Python3.9", "python.3.9" + "python-foo", + "python3abc", + "python3.12b3", + "pyth0n3", + "", + "Python3.9", + "python.3.9", ]; - assert_cases(&invalid_cases, is_python_executable, "invalid_is_python_executable", false); + assert_cases( + &invalid_cases, + is_python_executable, + "invalid_is_python_executable", + false, + ); } #[test] fn valid_python_versions() { let valid_cases = ["3", "3.9", "4", "3.10", "49", "3.11.3"]; - assert_cases(&valid_cases, is_valid_python_version, "valid_python_versions", true); + assert_cases( + &valid_cases, + is_valid_python_version, + "valid_python_versions", + true, + ); } #[test] fn invalid_python_versions() { let invalid_cases = [ - "3.12b3", "3.12rc1", "3.12a1", - "3.12.post1", "3.12.1-foo", "3abc", "..", "" + "3.12b3", + "3.12rc1", + "3.12a1", + "3.12.post1", + "3.12.1-foo", + "3abc", + "..", + "", ]; - assert_cases(&invalid_cases, is_valid_python_version, "invalid_python_versions", false); + assert_cases( + &invalid_cases, + is_valid_python_version, + "invalid_python_versions", + false, + ); } } From 11e94a8b032d4403203abfe6ffaeb3e20e536107 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Tue, 18 Mar 2025 13:34:48 +0900 Subject: [PATCH 06/14] fix: change message for project --- crates/uv/src/commands/project/run.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 300b7d069..7e66abdf4 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1110,9 +1110,8 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl let version_part = executable.strip_prefix("python").unwrap_or(""); let current_executable_python_version = base_interpreter.python_version().only_release(); // Determine the environment type - let env_type = if project_found { "the project" } else { "the" }; + let env_type = if project_found { "project" } else { "virtual" }; - // Construct the message dynamically let message_suffix = if project_found { format!( "Did you mean to change the environment to Python {version_part} with `uv run -p {version_part} python`?" @@ -1123,7 +1122,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl ) }; anyhow!( - "`{}` not available in {} environment, which uses python `{}`. {}", + "`{}` not available in the {} environment, which uses python `{}`. {}", executable, env_type, current_executable_python_version, From 286c99b09b1ee66644e1a68315645dcdd99cd1ce Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Tue, 18 Mar 2025 13:36:04 +0900 Subject: [PATCH 07/14] docs: update docstring --- crates/uv/src/commands/project/run.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 7e66abdf4..d21ec36fd 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1578,8 +1578,8 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result { } /// Matches valid Python executable names: -/// - ✅ "python", "python3", "python3.9", "python4", "python3.10", "python3.13.3" -/// - ❌ "python39", "python3abc", "python3.12b3", "", "python-foo" +/// - ✅ "python", "python39", "python3", "python3.9", "python4", "python3.10", "python3.13.3" +/// - ❌ "python3abc", "python3.12b3", "", "python-foo" fn is_python_executable(executable_command: &str) -> bool { executable_command .strip_prefix("python") From e4a2fa1c1335af89e960036526d8456baef25fd9 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sat, 19 Apr 2025 15:52:46 +0900 Subject: [PATCH 08/14] refactor: rename version_part to specified_version --- crates/uv/src/commands/project/run.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index d21ec36fd..c22b82040 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1092,7 +1092,7 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl // Ensure `VIRTUAL_ENV` is set. if interpreter.is_virtualenv() { process.env(EnvVars::VIRTUAL_ENV, interpreter.sys_prefix().as_os_str()); - }; + } // Spawn and wait for completion // Standard input, output, and error streams are all inherited @@ -1107,18 +1107,18 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl if err.kind() == std::io::ErrorKind::NotFound && is_python_executable(&executable) { // Get version from python command string // e.g. python3.12 -> "3.12" or "" if python version not specified. - let version_part = executable.strip_prefix("python").unwrap_or(""); + let specified_version = executable.strip_prefix("python").unwrap_or(""); let current_executable_python_version = base_interpreter.python_version().only_release(); // Determine the environment type let env_type = if project_found { "project" } else { "virtual" }; let message_suffix = if project_found { format!( - "Did you mean to change the environment to Python {version_part} with `uv run -p {version_part} python`?" + "Did you mean to change the environment to Python {specified_version} with `uv run -p {specified_version} python`?" ) } else { format!( - "Did you mean to search for a Python {version_part} environment with `uv run -p {version_part} python`?" + "Did you mean to search for a Python {specified_version} environment with `uv run -p {specified_version} python`?" ) }; anyhow!( From 0cb2d5e35dddaabf6eef93410643bbc58e8098fd Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sun, 20 Apr 2025 16:18:33 +0900 Subject: [PATCH 09/14] refactor: update code to be more idiomatic --- crates/uv/src/commands/project/run.rs | 176 +++++++++++++------------- 1 file changed, 90 insertions(+), 86 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index c22b82040..a308ea30d 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1101,35 +1101,35 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl .spawn() .map_err(|err| { let executable: Cow<'_, str> = command.display_executable(); - // Special case for providing meaningful error message when users - // attempt to invoke python. E.g. "python3.11". - // Will not work if patch version is provided. I.E. "python3.11.9" - if err.kind() == std::io::ErrorKind::NotFound && is_python_executable(&executable) { - // Get version from python command string - // e.g. python3.12 -> "3.12" or "" if python version not specified. - let specified_version = executable.strip_prefix("python").unwrap_or(""); - let current_executable_python_version = base_interpreter.python_version().only_release(); - // Determine the environment type - let env_type = if project_found { "project" } else { "virtual" }; - - let message_suffix = if project_found { - format!( - "Did you mean to change the environment to Python {specified_version} with `uv run -p {specified_version} python`?" - ) - } else { - format!( - "Did you mean to search for a Python {specified_version} environment with `uv run -p {specified_version} python`?" - ) - }; - anyhow!( - "`{}` not available in the {} environment, which uses python `{}`. {}", - executable, - env_type, - current_executable_python_version, - message_suffix - ) - } else { - err.into() + match err.kind() { + // Special case for providing meaningful error message when users + // attempt to invoke python. E.g. "python3.11". + // Will not work if patch version is provided. I.E. "python3.11.9" + std::io::ErrorKind::NotFound => python_executable_version(&executable) + .map_or_else( + || err.into(), + |specified_version| { + let current_executable_python_version = base_interpreter.python_version().only_release(); + let env_type = if project_found { "project" } else { "virtual" }; + let message_suffix = if project_found { + format!( + "Did you mean to change the environment to Python {specified_version} with `uv run -p {specified_version} python`?" + ) + } else { + format!( + "Did you mean to search for a Python {specified_version} environment with `uv run -p {specified_version} python`?" + ) + }; + anyhow!( + "`{}` not available in the {} environment, which uses python `{}`. {}", + executable, + env_type, + current_executable_python_version, + message_suffix + ) + } + ), + _ => err.into(), } }) .with_context(|| format!("Failed to spawn: `{}`", command.display_executable()))?; @@ -1577,97 +1577,101 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result { .with_context(|| format!("invalid value for {}", EnvVars::UV_RUN_RECURSION_DEPTH)) } -/// Matches valid Python executable names: -/// - ✅ "python", "python39", "python3", "python3.9", "python4", "python3.10", "python3.13.3" -/// - ❌ "python3abc", "python3.12b3", "", "python-foo" -fn is_python_executable(executable_command: &str) -> bool { +/// Matches valid Python executable names and returns the version part if valid: +/// - ✅ "python" -> Some("") +/// - ✅ "python39" -> Some("39") +/// - ✅ "python3" -> Some("3") +/// - ✅ "python3.9" -> Some("3.9") +/// - ❌ "python3abc" -> None +/// - ❌ "python3.12b3" -> None +/// - ❌ "" -> None +/// - ❌ "python-foo" -> None +fn python_executable_version(executable_command: &str) -> Option<&str> { executable_command .strip_prefix("python") - .is_some_and(|version| version.is_empty() || is_valid_python_version(version)) + .filter(|version| version.is_empty() || validate_python_version(version).is_ok()) } -/// Checks if a version string is a valid Python major.minor.patch version. -fn is_valid_python_version(version: &str) -> bool { - PythonVersion::from_str(version).is_ok_and(|ver| { - ver.is_stable() && - // Should not contain post info. E.g. "3.12b3" - !ver.is_post() - }) +/// Validates if a version string is a valid Python major.minor.patch version. +/// Returns Ok(()) if valid, Err with description if invalid. +fn validate_python_version(version: &str) -> anyhow::Result<()> { + match PythonVersion::from_str(version) { + Ok(ver) if ver.is_stable() && !ver.is_post() => Ok(()), + _ => Err(anyhow!("invalid python version: {}", version)), + } } + #[cfg(test)] mod tests { - use super::{is_python_executable, is_valid_python_version}; + use super::{python_executable_version, validate_python_version}; /// Helper function for asserting test cases. - /// - If `expected_result` is `true`, it expects the function to return `true` (valid cases). - /// - If `expected_result` is `false`, it expects the function to return `false` (invalid cases). - fn assert_cases bool>( - cases: &[&str], + /// - If `expected_result` is `Some(version)`, it expects the function to return that version. + /// - If `expected_result` is `None`, it expects the function to return None (invalid cases). + fn assert_cases Option<&str>>( + cases: &[(&str, Option<&str>)], func: F, test_name: &str, - expected_result: bool, ) { - for &case in cases { + for &(case, expected) in cases { let result = func(case); assert_eq!( - result, expected_result, - "{test_name}: Expected `{expected_result}`, but got `{result}` for case `{case}`" + result, expected, + "{test_name}: Expected `{expected:?}`, but got `{result:?}` for case `{case}`" ); } } #[test] - fn valid_is_python_executable() { + fn valid_python_executable_version() { let valid_cases = [ - "python3", - "python3.9", - "python3.10", - "python4", - "python", - "python3.11.3", - "python39", // Still a valid executable, although likely a typo + ("python3", Some("3")), + ("python3.9", Some("3.9")), + ("python3.10", Some("3.10")), + ("python4", Some("4")), + ("python", Some("")), + ("python3.11.3", Some("3.11.3")), + ("python39", Some("39")), // Still a valid executable, although likely a typo ]; assert_cases( &valid_cases, - is_python_executable, - "valid_is_python_executable", - true, + python_executable_version, + "valid_python_executable_version", ); } #[test] - fn invalid_is_python_executable() { + fn invalid_python_executable_version() { let invalid_cases = [ - "python-foo", - "python3abc", - "python3.12b3", - "pyth0n3", - "", - "Python3.9", - "python.3.9", + ("python-foo", None), + ("python3abc", None), + ("python3.12b3", None), + ("pyth0n3", None), + ("", None), + ("Python3.9", None), + ("python.3.9", None), ]; assert_cases( &invalid_cases, - is_python_executable, - "invalid_is_python_executable", - false, + python_executable_version, + "invalid_python_executable_version", ); } #[test] fn valid_python_versions() { - let valid_cases = ["3", "3.9", "4", "3.10", "49", "3.11.3"]; - assert_cases( - &valid_cases, - is_valid_python_version, - "valid_python_versions", - true, - ); + let valid_cases: &[&str] = &["3", "3.9", "4", "3.10", "49", "3.11.3"]; + for version in valid_cases { + assert!( + validate_python_version(version).is_ok(), + "Expected version `{version}` to be valid" + ); + } } #[test] fn invalid_python_versions() { - let invalid_cases = [ + let invalid_cases: &[&str] = &[ "3.12b3", "3.12rc1", "3.12a1", @@ -1677,11 +1681,11 @@ mod tests { "..", "", ]; - assert_cases( - &invalid_cases, - is_valid_python_version, - "invalid_python_versions", - false, - ); + for version in invalid_cases { + assert!( + validate_python_version(version).is_err(), + "Expected version `{version}` to be invalid" + ); + } } } From e6a801d0fe3deb62318b1e5aed5a18a3e350517b Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sun, 20 Apr 2025 22:18:51 +0900 Subject: [PATCH 10/14] feat: add support for .exe and absolute path python --- crates/uv/src/commands/project/run.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index a308ea30d..004349115 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1579,17 +1579,28 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result { /// Matches valid Python executable names and returns the version part if valid: /// - ✅ "python" -> Some("") -/// - ✅ "python39" -> Some("39") +/// - ✅ "/usr/bin/python3.9" -> Some("3.9") +/// - ✅ "/path/to/python39" -> Some("39") /// - ✅ "python3" -> Some("3") -/// - ✅ "python3.9" -> Some("3.9") +/// - ✅ "python3.exe" -> Some("3") +/// - ✅ "python3.9.exe" -> Some("3.9") /// - ❌ "python3abc" -> None /// - ❌ "python3.12b3" -> None /// - ❌ "" -> None /// - ❌ "python-foo" -> None +/// - ❌ "Python" -> None // Case-sensitive fn python_executable_version(executable_command: &str) -> Option<&str> { - executable_command - .strip_prefix("python") - .filter(|version| version.is_empty() || validate_python_version(version).is_ok()) + const PYTHON_MARKER: &str = "python"; + + // Strip suffix for windows .exe + let command = executable_command + .strip_suffix(".exe") + .unwrap_or(executable_command); + let version_start = command.rfind(PYTHON_MARKER)? + PYTHON_MARKER.len(); + // Retrieve python version string. E.g. "python3.12" -> "3.12" + let version = command.get(version_start..)?; + + Some(version).filter(|&v| v.is_empty() || validate_python_version(v).is_ok()) } /// Validates if a version string is a valid Python major.minor.patch version. @@ -1628,6 +1639,9 @@ mod tests { ("python3", Some("3")), ("python3.9", Some("3.9")), ("python3.10", Some("3.10")), + ("/usr/bin/python3.9", Some("3.9")), + ("python3.9.exe", Some("3.9")), + ("python3.9.exe", Some("3.9")), ("python4", Some("4")), ("python", Some("")), ("python3.11.3", Some("3.11.3")), From 0c6b069236d04fea5c97b6515008cb76447b5440 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Thu, 24 Apr 2025 18:02:43 +0200 Subject: [PATCH 11/14] Add snapshot tests for running missing Python versions --- crates/uv/tests/it/run.rs | 151 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index f970ed8a0..d69f8122f 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -139,6 +139,157 @@ fn run_with_python_version() -> Result<()> { Ok(()) } +#[test] +fn run_matching_python_patch_version() -> Result<()> { + let context = TestContext::new_with_versions(&[]) + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_managed_python_dirs(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.11, <4" + dependencies = [] + "# + })?; + + // Install a patch version + uv_snapshot!(context.filters(), context.python_install().arg("3.11.9"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.11.9 in [TIME] + + cpython-3.11.9-[PLATFORM] + "); + + // Try runnning a patch version with the same as installed. + uv_snapshot!(context.filters(), context.run().arg("python3.11.9"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.11.9 + Creating virtual environment at: .venv + Resolved 1 package in [TIME] + Audited in [TIME] + error: Failed to spawn: `python3.11.9` + Caused by: `python3.11.9` not available in the project environment, which uses python `3.11.9`. Did you mean to change the environment to Python 3.11.9 with `uv run -p 3.11.9 python`? + "); + + Ok(()) +} + +#[test] +fn run_missing_python_minor_version_no_project() -> Result<()> { + let context = TestContext::new_with_versions(&["3.12"]); + + uv_snapshot!(context.filters(), context.run().arg("python3.11"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to spawn: `python3.11` + Caused by: `python3.11` not available in the virtual environment, which uses python `3.12.[X]`. Did you mean to search for a Python 3.11 environment with `uv run -p 3.11 python`? + "); + + Ok(()) +} + +#[test] +fn run_missing_python_patch_version_no_project() -> Result<()> { + let context = TestContext::new_with_versions(&["3.12"]) + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_managed_python_dirs(); + + uv_snapshot!(context.filters(), context.run().arg("python3.11.9"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to spawn: `python3.11.9` + Caused by: `python3.11.9` not available in the virtual environment, which uses python `3.12.[X]`. Did you mean to search for a Python 3.11.9 environment with `uv run -p 3.11.9 python`? + "); + + Ok(()) +} + +#[test] +fn run_missing_python_minor_version_in_project() -> Result<()> { + let context = TestContext::new_with_versions(&["3.12"]) + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_managed_python_dirs(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.12, <4" + dependencies = [] + "# + })?; + + uv_snapshot!(context.filters(), context.run().arg("python3.11"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved 1 package in [TIME] + Audited in [TIME] + error: Failed to spawn: `python3.11` + Caused by: `python3.11` not available in the project environment, which uses python `3.12.[X]`. Did you mean to change the environment to Python 3.11 with `uv run -p 3.11 python`? + "); + + Ok(()) +} + +#[test] +fn run_missing_python_patch_version_in_project() -> Result<()> { + let context = TestContext::new_with_versions(&["3.12"]) + .with_filtered_python_keys() + .with_filtered_exe_suffix() + .with_managed_python_dirs(); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str(indoc! { r#" + [project] + name = "foo" + version = "1.0.0" + requires-python = ">=3.12, <4" + dependencies = [] + "# + })?; + + uv_snapshot!(context.filters(), context.run().arg("python3.11.9"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Resolved 1 package in [TIME] + Audited in [TIME] + error: Failed to spawn: `python3.11.9` + Caused by: `python3.11.9` not available in the project environment, which uses python `3.12.[X]`. Did you mean to change the environment to Python 3.11.9 with `uv run -p 3.11.9 python`? + "); + + Ok(()) +} + #[test] fn run_args() -> Result<()> { let context = TestContext::new("3.12"); From 73c5243c37bb3f2d0ccb63ca1320613bd95e3311 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Thu, 24 Apr 2025 18:07:46 +0200 Subject: [PATCH 12/14] snapshot tests fixes --- crates/uv/tests/it/run.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index d69f8122f..46a56998f 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -167,7 +167,7 @@ fn run_matching_python_patch_version() -> Result<()> { + cpython-3.11.9-[PLATFORM] "); - // Try runnning a patch version with the same as installed. + // Try running a patch version with the same as installed. uv_snapshot!(context.filters(), context.run().arg("python3.11.9"), @r" success: false exit_code: 2 @@ -186,7 +186,7 @@ fn run_matching_python_patch_version() -> Result<()> { } #[test] -fn run_missing_python_minor_version_no_project() -> Result<()> { +fn run_missing_python_minor_version_no_project() { let context = TestContext::new_with_versions(&["3.12"]); uv_snapshot!(context.filters(), context.run().arg("python3.11"), @r" @@ -198,12 +198,10 @@ fn run_missing_python_minor_version_no_project() -> Result<()> { error: Failed to spawn: `python3.11` Caused by: `python3.11` not available in the virtual environment, which uses python `3.12.[X]`. Did you mean to search for a Python 3.11 environment with `uv run -p 3.11 python`? "); - - Ok(()) } #[test] -fn run_missing_python_patch_version_no_project() -> Result<()> { +fn run_missing_python_patch_version_no_project() { let context = TestContext::new_with_versions(&["3.12"]) .with_filtered_python_keys() .with_filtered_exe_suffix() @@ -218,8 +216,6 @@ fn run_missing_python_patch_version_no_project() -> Result<()> { error: Failed to spawn: `python3.11.9` Caused by: `python3.11.9` not available in the virtual environment, which uses python `3.12.[X]`. Did you mean to search for a Python 3.11.9 environment with `uv run -p 3.11.9 python`? "); - - Ok(()) } #[test] From 7433028ea528107831a3b35d976b66e5195aab41 Mon Sep 17 00:00:00 2001 From: John Mumm Date: Fri, 25 Apr 2025 11:49:37 +0200 Subject: [PATCH 13/14] Ensure system Python isn't used in snapshot tests --- crates/uv/tests/it/run.rs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 46a56998f..87345ffc5 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -188,8 +188,11 @@ fn run_matching_python_patch_version() -> Result<()> { #[test] fn run_missing_python_minor_version_no_project() { let context = TestContext::new_with_versions(&["3.12"]); + let bin_dir = context.temp_dir.child("bin"); - uv_snapshot!(context.filters(), context.run().arg("python3.11"), @r" + uv_snapshot!(context.filters(), context.run() + .arg("python3.11") + .env(EnvVars::PATH, bin_dir.as_os_str()), @r" success: false exit_code: 2 ----- stdout ----- @@ -206,8 +209,11 @@ fn run_missing_python_patch_version_no_project() { .with_filtered_python_keys() .with_filtered_exe_suffix() .with_managed_python_dirs(); + let bin_dir = context.temp_dir.child("bin"); - uv_snapshot!(context.filters(), context.run().arg("python3.11.9"), @r" + uv_snapshot!(context.filters(), context.run() + .arg("python3.11.9") + .env(EnvVars::PATH, bin_dir.as_os_str()), @r" success: false exit_code: 2 ----- stdout ----- @@ -224,6 +230,7 @@ fn run_missing_python_minor_version_in_project() -> Result<()> { .with_filtered_python_keys() .with_filtered_exe_suffix() .with_managed_python_dirs(); + let bin_dir = context.temp_dir.child("bin"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! { r#" @@ -235,7 +242,9 @@ fn run_missing_python_minor_version_in_project() -> Result<()> { "# })?; - uv_snapshot!(context.filters(), context.run().arg("python3.11"), @r" + uv_snapshot!(context.filters(), context.run() + .arg("python3.11") + .env(EnvVars::PATH, bin_dir.as_os_str()), @r" success: false exit_code: 2 ----- stdout ----- @@ -258,6 +267,7 @@ fn run_missing_python_patch_version_in_project() -> Result<()> { .with_filtered_python_keys() .with_filtered_exe_suffix() .with_managed_python_dirs(); + let bin_dir = context.temp_dir.child("bin"); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! { r#" @@ -269,7 +279,9 @@ fn run_missing_python_patch_version_in_project() -> Result<()> { "# })?; - uv_snapshot!(context.filters(), context.run().arg("python3.11.9"), @r" + uv_snapshot!(context.filters(), context.run() + .arg("python3.11.9") + .env(EnvVars::PATH, bin_dir.as_os_str()), @r" success: false exit_code: 2 ----- stdout ----- From 99765005f3ef2e5e4da44a83a300980bdcd49b42 Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sat, 26 Apr 2025 17:52:22 +0900 Subject: [PATCH 14/14] feat: provide meaningful error message when python patch version is provided --- crates/uv/src/commands/project/run.rs | 144 +++++++++++++++++--------- crates/uv/tests/it/run.rs | 6 +- 2 files changed, 98 insertions(+), 52 deletions(-) diff --git a/crates/uv/src/commands/project/run.rs b/crates/uv/src/commands/project/run.rs index 004349115..6dabb0f6a 100644 --- a/crates/uv/src/commands/project/run.rs +++ b/crates/uv/src/commands/project/run.rs @@ -1111,6 +1111,14 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl |specified_version| { let current_executable_python_version = base_interpreter.python_version().only_release(); let env_type = if project_found { "project" } else { "virtual" }; + // Specified version is equal. In this case, + let message_prefix = if specified_version.patch().is_some() { + let major = specified_version.major(); + let minor = specified_version.minor(); + format!("Please omit patch version. Try: `uv run python{major}.{minor}`.") + } else { + format!("`{executable}` not available in the {env_type} environment, which uses python `{current_executable_python_version}`.") + }; let message_suffix = if project_found { format!( "Did you mean to change the environment to Python {specified_version} with `uv run -p {specified_version} python`?" @@ -1121,10 +1129,8 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl ) }; anyhow!( - "`{}` not available in the {} environment, which uses python `{}`. {}", - executable, - env_type, - current_executable_python_version, + "{} {}", + message_prefix, message_suffix ) } @@ -1580,72 +1586,95 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result { /// Matches valid Python executable names and returns the version part if valid: /// - ✅ "python" -> Some("") /// - ✅ "/usr/bin/python3.9" -> Some("3.9") -/// - ✅ "/path/to/python39" -> Some("39") +/// - ✅ "python39" -> Some("39") /// - ✅ "python3" -> Some("3") /// - ✅ "python3.exe" -> Some("3") /// - ✅ "python3.9.exe" -> Some("3.9") +/// - ✅ "python3.9.EXE" -> Some("3.9") /// - ❌ "python3abc" -> None /// - ❌ "python3.12b3" -> None /// - ❌ "" -> None /// - ❌ "python-foo" -> None -/// - ❌ "Python" -> None // Case-sensitive -fn python_executable_version(executable_command: &str) -> Option<&str> { +/// - ❌ "Python3.9" -> None // Case-sensitive prefix +fn python_executable_version(executable_command: &str) -> Option { const PYTHON_MARKER: &str = "python"; - // Strip suffix for windows .exe - let command = executable_command - .strip_suffix(".exe") - .unwrap_or(executable_command); - let version_start = command.rfind(PYTHON_MARKER)? + PYTHON_MARKER.len(); - // Retrieve python version string. E.g. "python3.12" -> "3.12" - let version = command.get(version_start..)?; + // Find the python prefix (case-sensitive) + let version_start = executable_command.rfind(PYTHON_MARKER)? + PYTHON_MARKER.len(); + let mut version = &executable_command[version_start..]; - Some(version).filter(|&v| v.is_empty() || validate_python_version(v).is_ok()) + // Strip any .exe suffixes (case-insensitive) + while version.to_ascii_lowercase().ends_with(".exe") { + version = &version[..version.len() - 4]; + } + if version.is_empty() { + return None; + } + parse_valid_python_version(version).ok() } -/// Validates if a version string is a valid Python major.minor.patch version. -/// Returns Ok(()) if valid, Err with description if invalid. -fn validate_python_version(version: &str) -> anyhow::Result<()> { +/// Returns Ok(()) if a version string is a valid Python major.minor.patch version. +fn parse_valid_python_version(version: &str) -> anyhow::Result { match PythonVersion::from_str(version) { - Ok(ver) if ver.is_stable() && !ver.is_post() => Ok(()), + Ok(ver) if ver.is_stable() && !ver.is_post() => Ok(ver), _ => Err(anyhow!("invalid python version: {}", version)), } } #[cfg(test)] mod tests { - use super::{python_executable_version, validate_python_version}; + use uv_python::PythonVersion; + + use super::{parse_valid_python_version, python_executable_version}; /// Helper function for asserting test cases. /// - If `expected_result` is `Some(version)`, it expects the function to return that version. /// - If `expected_result` is `None`, it expects the function to return None (invalid cases). - fn assert_cases Option<&str>>( + fn assert_cases Option>( cases: &[(&str, Option<&str>)], func: F, test_name: &str, ) { for &(case, expected) in cases { let result = func(case); - assert_eq!( - result, expected, - "{test_name}: Expected `{expected:?}`, but got `{result:?}` for case `{case}`" - ); + match (result, expected) { + (Some(version), Some(expected_str)) => { + assert_eq!( + version.to_string(), + expected_str, + "{test_name}: Expected version `{expected_str}`, but got `{version}` for case `{case}`" + ); + } + (None, None) => { + // Test passed - both are None + } + (Some(version), None) => { + panic!("{test_name}: Expected None, but got `{version}` for case `{case}`"); + } + (None, Some(expected_str)) => { + panic!( + "{test_name}: Expected `{expected_str}`, but got None for case `{case}`" + ); + } + } } } #[test] fn valid_python_executable_version() { let valid_cases = [ + // Base cases ("python3", Some("3")), ("python3.9", Some("3.9")), - ("python3.10", Some("3.10")), + // Path handling ("/usr/bin/python3.9", Some("3.9")), + // Case-sensitive python prefix, case-insensitive .exe ("python3.9.exe", Some("3.9")), - ("python3.9.exe", Some("3.9")), - ("python4", Some("4")), - ("python", Some("")), + ("python3.9.EXE", Some("3.9")), + ("python3.9.exe.EXE", Some("3.9")), + // Version variations ("python3.11.3", Some("3.11.3")), - ("python39", Some("39")), // Still a valid executable, although likely a typo + ("python39", Some("39")), ]; assert_cases( &valid_cases, @@ -1657,13 +1686,20 @@ mod tests { #[test] fn invalid_python_executable_version() { let invalid_cases = [ - ("python-foo", None), - ("python3abc", None), - ("python3.12b3", None), - ("pyth0n3", None), + // Empty string ("", None), + ("python", None), // No version specified + // Case-sensitive python prefix ("Python3.9", None), - ("python.3.9", None), + ("PYTHON3.9", None), + ("Python3.9.exe", None), + ("Python3.9.EXE", None), + // Invalid version formats + ("python3.12b3", None), + ("python3.12.post1", None), + // Invalid .exe placement/format + ("python.exe3.9", None), + ("python3.9.ex", None), ]; assert_cases( &invalid_cases, @@ -1674,30 +1710,40 @@ mod tests { #[test] fn valid_python_versions() { - let valid_cases: &[&str] = &["3", "3.9", "4", "3.10", "49", "3.11.3"]; - for version in valid_cases { + let valid_cases = [ + ("3", "3"), + ("3.9", "3.9"), + ("3.10", "3.10"), + ("3.11.3", "3.11.3"), + ]; + for (version, expected) in valid_cases { + let result = parse_valid_python_version(version); assert!( - validate_python_version(version).is_ok(), - "Expected version `{version}` to be valid" + result.is_ok(), + "Expected version `{version}` to be valid, but got error: {:?}", + result.err() + ); + assert_eq!( + result.unwrap().to_string(), + expected, + "Version string mismatch for {version}" ); } } #[test] fn invalid_python_versions() { - let invalid_cases: &[&str] = &[ - "3.12b3", - "3.12rc1", - "3.12a1", - "3.12.post1", - "3.12.1-foo", - "3abc", - "..", - "", + let invalid_cases = [ + "3.12b3", // Pre-release + "3.12rc1", // Release candidate + "3.12.post1", // Post-release + "3abc", // Invalid format + "..", // Invalid format + "", // Empty string ]; for version in invalid_cases { assert!( - validate_python_version(version).is_err(), + parse_valid_python_version(version).is_err(), "Expected version `{version}` to be invalid" ); } diff --git a/crates/uv/tests/it/run.rs b/crates/uv/tests/it/run.rs index 87345ffc5..ba0f64203 100644 --- a/crates/uv/tests/it/run.rs +++ b/crates/uv/tests/it/run.rs @@ -179,7 +179,7 @@ fn run_matching_python_patch_version() -> Result<()> { Resolved 1 package in [TIME] Audited in [TIME] error: Failed to spawn: `python3.11.9` - Caused by: `python3.11.9` not available in the project environment, which uses python `3.11.9`. Did you mean to change the environment to Python 3.11.9 with `uv run -p 3.11.9 python`? + Caused by: Please omit patch version. Try: `uv run python3.11`. Did you mean to change the environment to Python 3.11.9 with `uv run -p 3.11.9 python`? "); Ok(()) @@ -220,7 +220,7 @@ fn run_missing_python_patch_version_no_project() { ----- stderr ----- error: Failed to spawn: `python3.11.9` - Caused by: `python3.11.9` not available in the virtual environment, which uses python `3.12.[X]`. Did you mean to search for a Python 3.11.9 environment with `uv run -p 3.11.9 python`? + Caused by: Please omit patch version. Try: `uv run python3.11`. Did you mean to search for a Python 3.11.9 environment with `uv run -p 3.11.9 python`? "); } @@ -292,7 +292,7 @@ fn run_missing_python_patch_version_in_project() -> Result<()> { Resolved 1 package in [TIME] Audited in [TIME] error: Failed to spawn: `python3.11.9` - Caused by: `python3.11.9` not available in the project environment, which uses python `3.12.[X]`. Did you mean to change the environment to Python 3.11.9 with `uv run -p 3.11.9 python`? + Caused by: Please omit patch version. Try: `uv run python3.11`. Did you mean to change the environment to Python 3.11.9 with `uv run -p 3.11.9 python`? "); Ok(())