diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index b2c48a1900f..b3bde5187b1 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::{debug, trace, warn}; -use uv_cache::{Cache, CacheBucket, CachedByTimestamp, Freshness}; +use uv_cache::{Cache, CacheBucket, CacheEntry, CachedByTimestamp, Freshness}; use uv_cache_info::Timestamp; use uv_cache_key::cache_digest; use uv_fs::{ @@ -69,7 +69,8 @@ pub struct Interpreter { impl Interpreter { /// Detect the interpreter info for the given Python executable. pub fn query(executable: impl AsRef, cache: &Cache) -> Result { - let info = InterpreterInfo::query_cached(executable.as_ref(), cache)?; + let executable = executable.as_ref(); + let info = InterpreterInfo::query_cached(executable, cache)?; debug_assert!( info.sys_executable.is_absolute(), @@ -97,10 +98,23 @@ impl Interpreter { tags: OnceLock::new(), target: None, prefix: None, - real_executable: executable.as_ref().to_path_buf(), + real_executable: executable.to_path_buf(), }) } + /// Remove any cached metadata for the given Python executable. + pub fn clear_cache(executable: impl AsRef, cache: &Cache) -> Result<(), Error> { + let absolute = std::path::absolute(executable.as_ref())?; + let canonical = canonicalize_executable(&absolute)?; + let cache_entry = InterpreterInfo::cache_entry(&absolute, &canonical, cache); + + match fs::remove_file(cache_entry.path()) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err.into()), + } + } + /// Return a new [`Interpreter`] with the given virtual environment root. #[must_use] pub fn with_virtualenv(self, virtualenv: VirtualEnvironment) -> Self { @@ -1117,6 +1131,41 @@ impl InterpreterInfo { Ok(()) } + /// Return the cache entry for an interpreter's absolute and canonical executable paths. + fn cache_entry(absolute: &Path, canonical: &Path, cache: &Cache) -> CacheEntry { + let python_executable = env::var_os(EnvVars::PYTHONEXECUTABLE).map(PathBuf::from); + let pyvenv_launcher = env::var_os(EnvVars::PYVENV_LAUNCHER).map(PathBuf::from); + + cache.entry( + CacheBucket::Interpreter, + // Shard interpreter metadata by host architecture, operating system, and version, to + // invalidate the cache (e.g.) on OS upgrades. + cache_digest(&( + ARCH, + uv_platform::OsType::from_env() + .map(|os_type| os_type.to_string()) + .unwrap_or_default(), + uv_platform::OsRelease::from_env() + .map(|os_release| os_release.to_string()) + .unwrap_or_default(), + )), + // We use the absolute path for the cache entry to avoid cache collisions for relative + // paths. But we don't want to query the executable with symbolic links resolved because + // that can change reported values, e.g., `sys.executable`. We include the canonical + // path in the cache entry as well, otherwise we can have cache collisions if an + // absolute path refers to different interpreters with matching ctimes, e.g., if you + // have a `.venv/bin/python` pointing to both Python 3.12 and Python 3.13 that were + // modified at the same time. + // + // Launcher overrides can also change the reported executable and virtual environment + // without changing either executable path. + format!( + "{}.msgpack", + cache_digest(&(absolute, canonical, &python_executable, &pyvenv_launcher)) + ), + ) + } + /// A wrapper around [`markers::query_interpreter_info`] to cache the computed markers. /// /// Running a Python script is (relatively) expensive, and the markers won't change @@ -1150,37 +1199,7 @@ impl InterpreterInfo { }; let canonical = canonicalize_executable(&absolute).map_err(handle_io_error)?; - let python_executable = env::var_os(EnvVars::PYTHONEXECUTABLE).map(PathBuf::from); - let pyvenv_launcher = env::var_os(EnvVars::PYVENV_LAUNCHER).map(PathBuf::from); - - let cache_entry = cache.entry( - CacheBucket::Interpreter, - // Shard interpreter metadata by host architecture, operating system, and version, to - // invalidate the cache (e.g.) on OS upgrades. - cache_digest(&( - ARCH, - uv_platform::OsType::from_env() - .map(|os_type| os_type.to_string()) - .unwrap_or_default(), - uv_platform::OsRelease::from_env() - .map(|os_release| os_release.to_string()) - .unwrap_or_default(), - )), - // We use the absolute path for the cache entry to avoid cache collisions for relative - // paths. But we don't want to query the executable with symbolic links resolved because - // that can change reported values, e.g., `sys.executable`. We include the canonical - // path in the cache entry as well, otherwise we can have cache collisions if an - // absolute path refers to different interpreters with matching ctimes, e.g., if you - // have a `.venv/bin/python` pointing to both Python 3.12 and Python 3.13 that were - // modified at the same time. - // - // Launcher overrides can also change the reported executable and virtual environment - // without changing either executable path. - format!( - "{}.msgpack", - cache_digest(&(&absolute, &canonical, &python_executable, &pyvenv_launcher)) - ), - ); + let cache_entry = Self::cache_entry(&absolute, &canonical, cache); // We check the timestamp of the canonicalized executable to check if an underlying // interpreter has been modified. @@ -1342,8 +1361,10 @@ fn python_home(interpreter: &Path) -> Option { mod tests { use std::str::FromStr; + use anyhow::Result; use fs_err as fs; use indoc::{formatdoc, indoc}; + use serde_json::Value; use tempfile::tempdir; use uv_cache::{Cache, CacheBucket}; @@ -1352,12 +1373,8 @@ mod tests { use crate::Interpreter; - #[tokio::test] - async fn test_cache_invalidation() { - let mock_dir = tempdir().unwrap(); - let mocked_interpreter = mock_dir.path().join("python"); - let query_log = mock_dir.path().join("queries"); - let json = indoc! {r##" + fn mocked_interpreter_response() -> &'static str { + indoc! {r##" { "result": "success", "platform": { @@ -1415,7 +1432,14 @@ mod tests { "debug_enabled": false } "##} - .replace( + } + + #[tokio::test] + async fn test_cache_invalidation() { + let mock_dir = tempdir().unwrap(); + let mocked_interpreter = mock_dir.path().join("python"); + let query_log = mock_dir.path().join("queries"); + let json = mocked_interpreter_response().replace( "{sys_executable}", &mocked_interpreter.display().to_string(), ); @@ -1476,4 +1500,71 @@ mod tests { "queried\nqueried\n" ); } + + #[tokio::test] + async fn test_cache_eviction_with_unchanged_executable() -> Result<()> { + let mock_dir = tempdir()?; + let mocked_interpreter = mock_dir.path().join("python"); + let response_file = mock_dir.path().join("response.json"); + let query_count = mock_dir.path().join("queries"); + + let mut response = serde_json::from_str::(mocked_interpreter_response())?; + response["sys_executable"] = serde_json::to_value(&mocked_interpreter)?; + fs::write(&response_file, serde_json::to_vec(&response)?)?; + fs::write( + &mocked_interpreter, + formatdoc! {r#" + #!/bin/sh + printf '.' >> "{}" + cat "{}" + "#, query_count.display(), response_file.display()}, + )?; + fs::set_permissions( + &mocked_interpreter, + std::os::unix::fs::PermissionsExt::from_mode(0o770), + )?; + + let cache = Cache::temp()?.init().await?; + let original_version = Version::from_str("3.12.0")?; + let updated_version = Version::from_str("3.12.13")?; + + assert_eq!( + Interpreter::query(&mocked_interpreter, &cache)?.python_version(), + &original_version + ); + + response["markers"]["implementation_version"] = "3.12.13".into(); + response["markers"]["python_full_version"] = "3.12.13".into(); + fs::write(&response_file, serde_json::to_vec(&response)?)?; + + assert_eq!( + Interpreter::query(&mocked_interpreter, &cache)?.python_version(), + &original_version, + "an unchanged executable should retain its cached interpreter metadata" + ); + + Interpreter::clear_cache(&mocked_interpreter, &cache)?; + assert_eq!( + fs::read_to_string(&query_count)?, + ".", + "clearing cached metadata should not query the interpreter" + ); + assert_eq!( + Interpreter::query(&mocked_interpreter, &cache)?.python_version(), + &updated_version, + "clearing the cache should force the next query to run the interpreter" + ); + assert_eq!( + Interpreter::query(&mocked_interpreter, &cache)?.python_version(), + &updated_version, + "the next query should persist the updated interpreter metadata" + ); + assert_eq!( + fs::read_to_string(&query_count)?, + "..", + "the updated interpreter metadata should be cached again" + ); + + Ok(()) + } } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index f03b7ddb404..d2f0ebce95b 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1138,14 +1138,35 @@ fn discover_project_environment( return Ok(None); }; - match check_environment_compatibility( + let compatibility = check_environment_compatibility( &environment, EnvironmentKind::Project, python_request, python_preference, requires_python, cache, - ) { + ); + + // Conflicting versions for the same base interpreter indicate its cached metadata may be + // corrupted. Clear the entry before interpreter discovery can select stale metadata. + if matches!( + &compatibility, + Err(EnvironmentIncompatibilityError::PyenvVersionConflict(..)) + ) && let Ok(base_executable) = environment.interpreter().to_base_python() + && let Ok(base_interpreter) = Interpreter::query(&base_executable, cache) + && environment.uses(&base_interpreter) + && environment.interpreter().python_version() != base_interpreter.python_version() + { + debug!( + "Clearing cached interpreter info for {} after finding conflicting Python versions ({} and {})", + base_executable.user_display(), + base_interpreter.python_version(), + environment.interpreter().python_version(), + ); + Interpreter::clear_cache(&base_executable, cache)?; + } + + match compatibility { Ok(()) => Ok(Some(environment)), Err(err) if matches!(policy, ProjectEnvironmentPolicy::Preserve) => { if centralized {