mirror of
https://github.com/astral-sh/uv.git
synced 2025-07-07 13:25:00 +00:00
Merge 99765005f3
into f609e1ddaf
This commit is contained in:
commit
38e73f64e4
2 changed files with 369 additions and 1 deletions
|
@ -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::{Context, anyhow, bail};
|
||||
use futures::StreamExt;
|
||||
|
@ -27,7 +28,7 @@ use uv_installer::{SatisfiesResult, SitePackages};
|
|||
use uv_normalize::{DefaultExtras, DefaultGroups, PackageName};
|
||||
use uv_python::{
|
||||
EnvironmentPreference, Interpreter, PyVenvConfiguration, PythonDownloads, PythonEnvironment,
|
||||
PythonInstallation, PythonPreference, PythonRequest, PythonVersionFile,
|
||||
PythonInstallation, PythonPreference, PythonRequest, PythonVersion, PythonVersionFile,
|
||||
VersionFileDiscoveryOptions,
|
||||
};
|
||||
use uv_redacted::DisplaySafeUrl;
|
||||
|
@ -147,6 +148,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 {
|
||||
|
@ -854,6 +856,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()
|
||||
|
@ -1174,6 +1177,45 @@ hint: If you are running a script with `{}` in the shebang, you may need to incl
|
|||
// 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();
|
||||
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" };
|
||||
// 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`?"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Did you mean to search for a Python {specified_version} environment with `uv run -p {specified_version} python`?"
|
||||
)
|
||||
};
|
||||
anyhow!(
|
||||
"{} {}",
|
||||
message_prefix,
|
||||
message_suffix
|
||||
)
|
||||
}
|
||||
),
|
||||
_ => err.into(),
|
||||
}
|
||||
})
|
||||
.with_context(|| format!("Failed to spawn: `{}`", command.display_executable()))?;
|
||||
|
||||
run_to_completion(handle).await
|
||||
|
@ -1619,3 +1661,170 @@ fn read_recursion_depth_from_environment_variable() -> anyhow::Result<u32> {
|
|||
.parse::<u32>()
|
||||
.with_context(|| format!("invalid value for {}", EnvVars::UV_RUN_RECURSION_DEPTH))
|
||||
}
|
||||
|
||||
/// Matches valid Python executable names and returns the version part if valid:
|
||||
/// - ✅ "python" -> Some("")
|
||||
/// - ✅ "/usr/bin/python3.9" -> Some("3.9")
|
||||
/// - ✅ "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
|
||||
/// - ❌ "Python3.9" -> None // Case-sensitive prefix
|
||||
fn python_executable_version(executable_command: &str) -> Option<PythonVersion> {
|
||||
const PYTHON_MARKER: &str = "python";
|
||||
|
||||
// Find the python prefix (case-sensitive)
|
||||
let version_start = executable_command.rfind(PYTHON_MARKER)? + PYTHON_MARKER.len();
|
||||
let mut version = &executable_command[version_start..];
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
/// Returns Ok(()) if a version string is a valid Python major.minor.patch version.
|
||||
fn parse_valid_python_version(version: &str) -> anyhow::Result<PythonVersion> {
|
||||
match PythonVersion::from_str(version) {
|
||||
Ok(ver) if ver.is_stable() && !ver.is_post() => Ok(ver),
|
||||
_ => Err(anyhow!("invalid python version: {}", version)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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<F: Fn(&str) -> Option<PythonVersion>>(
|
||||
cases: &[(&str, Option<&str>)],
|
||||
func: F,
|
||||
test_name: &str,
|
||||
) {
|
||||
for &(case, expected) in cases {
|
||||
let result = func(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")),
|
||||
// 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")),
|
||||
("python3.9.exe.EXE", Some("3.9")),
|
||||
// Version variations
|
||||
("python3.11.3", Some("3.11.3")),
|
||||
("python39", Some("39")),
|
||||
];
|
||||
assert_cases(
|
||||
&valid_cases,
|
||||
python_executable_version,
|
||||
"valid_python_executable_version",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_python_executable_version() {
|
||||
let invalid_cases = [
|
||||
// Empty string
|
||||
("", None),
|
||||
("python", None), // No version specified
|
||||
// Case-sensitive python prefix
|
||||
("Python3.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,
|
||||
python_executable_version,
|
||||
"invalid_python_executable_version",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_python_versions() {
|
||||
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!(
|
||||
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 = [
|
||||
"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!(
|
||||
parse_valid_python_version(version).is_err(),
|
||||
"Expected version `{version}` to be invalid"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -139,6 +139,165 @@ 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 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
|
||||
----- 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: 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(())
|
||||
}
|
||||
|
||||
#[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")
|
||||
.env(EnvVars::PATH, bin_dir.as_os_str()), @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`?
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_missing_python_patch_version_no_project() {
|
||||
let context = TestContext::new_with_versions(&["3.12"])
|
||||
.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")
|
||||
.env(EnvVars::PATH, bin_dir.as_os_str()), @r"
|
||||
success: false
|
||||
exit_code: 2
|
||||
----- stdout -----
|
||||
|
||||
----- stderr -----
|
||||
error: Failed to spawn: `python3.11.9`
|
||||
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`?
|
||||
");
|
||||
}
|
||||
|
||||
#[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 bin_dir = context.temp_dir.child("bin");
|
||||
|
||||
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")
|
||||
.env(EnvVars::PATH, bin_dir.as_os_str()), @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 bin_dir = context.temp_dir.child("bin");
|
||||
|
||||
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")
|
||||
.env(EnvVars::PATH, bin_dir.as_os_str()), @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: 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(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_args() -> Result<()> {
|
||||
let context = TestContext::new("3.12");
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue