From 68f25e9f613e8217a1024802ba4c7bf62442eddb Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 12 Aug 2026 10:18:18 -0500 Subject: [PATCH 1/4] Refresh stale base interpreter metadata after virtualenv conflicts --- crates/uv-python/src/interpreter.rs | 22 +++- crates/uv/src/commands/project/mod.rs | 26 +++- crates/uv/tests/sync/sync.rs | 181 ++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 8 deletions(-) diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index b2c48a1900f..97b5795c93c 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -69,7 +69,16 @@ 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)?; + Self::query_with_cache(executable.as_ref(), cache, false) + } + + /// Query the interpreter directly and update its cached metadata. + pub fn query_fresh(executable: impl AsRef, cache: &Cache) -> Result { + Self::query_with_cache(executable.as_ref(), cache, true) + } + + fn query_with_cache(executable: &Path, cache: &Cache, refresh: bool) -> Result { + let info = InterpreterInfo::query_cached(executable, cache, refresh)?; debug_assert!( info.sys_executable.is_absolute(), @@ -97,7 +106,7 @@ impl Interpreter { tags: OnceLock::new(), target: None, prefix: None, - real_executable: executable.as_ref().to_path_buf(), + real_executable: executable.to_path_buf(), }) } @@ -1122,7 +1131,7 @@ impl InterpreterInfo { /// Running a Python script is (relatively) expensive, and the markers won't change /// unless the Python executable changes, so we use the executable's last modified /// time as a cache key. - fn query_cached(executable: &Path, cache: &Cache) -> Result { + fn query_cached(executable: &Path, cache: &Cache, refresh: bool) -> Result { let absolute = std::path::absolute(executable)?; // Provide a better error message if the link is broken or the file does not exist. Since @@ -1187,9 +1196,10 @@ impl InterpreterInfo { let modified = Timestamp::from_path(canonical).map_err(handle_io_error)?; // Read from the cache. - if cache - .freshness(&cache_entry, None, None) - .is_ok_and(Freshness::is_fresh) + if !refresh + && cache + .freshness(&cache_entry, None, None) + .is_ok_and(Freshness::is_fresh) { if let Ok(data) = fs::read(cache_entry.path()) { match rmp_serde::from_slice::>(&data) { diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index f03b7ddb404..be719118ef0 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1138,14 +1138,36 @@ 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, - ) { + ); + + // A launcher's shared library can change without updating the executable timestamp used to + // invalidate its cache. If the environment reveals conflicting versions for the same base + // interpreter, refresh that 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!( + "Refreshing cached interpreter info for {} after finding conflicting Python versions ({} and {})", + base_executable.user_display(), + base_interpreter.python_version(), + environment.interpreter().python_version(), + ); + Interpreter::query_fresh(&base_executable, cache)?; + } + + match compatibility { Ok(()) => Ok(Some(environment)), Err(err) if matches!(policy, ProjectEnvironmentPolicy::Preserve) => { if centralized { diff --git a/crates/uv/tests/sync/sync.rs b/crates/uv/tests/sync/sync.rs index 44107a37eef..431853c1eeb 100644 --- a/crates/uv/tests/sync/sync.rs +++ b/crates/uv/tests/sync/sync.rs @@ -5,6 +5,7 @@ use indoc::{formatdoc, indoc}; use insta::assert_snapshot; use predicates::prelude::predicate; use serde_json::json; +use std::path::PathBuf; #[cfg(feature = "test-git")] use std::process::Command; use tempfile::tempdir_in; @@ -12,7 +13,11 @@ use url::Url; use wiremock::matchers::{basic_auth, body_string_contains, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; +use uv_cache::{Cache, CacheBucket}; +use uv_cache_key::cache_digest; use uv_fs::Simplified; +use uv_platform::{OsRelease, OsType}; +use uv_python::{Interpreter, PythonEnvironment, canonicalize_executable}; use uv_static::EnvVars; use uv_test::packse::PackseServer; @@ -12695,6 +12700,182 @@ fn sync_workspace_member_build_constraints() -> Result<()> { Ok(()) } +/// Seed contradictory metadata for the virtual environment and its underlying interpreter. +fn stale_base_interpreter_cache(context: &TestContext) -> Result<(PathBuf, String, String)> { + let cache = Cache::from_path(context.cache_dir.path()); + let environment = PythonEnvironment::from_root(&context.venv, &cache)?; + let base_executable = environment.interpreter().to_base_python()?; + let actual_version = environment.interpreter().python_version().to_string(); + + Interpreter::query(&base_executable, &cache)?; + + let mut stale_version = actual_version.clone(); + let last_digit = stale_version + .pop() + .ok_or_else(|| anyhow!("Python version is empty"))?; + stale_version.push(if last_digit == '9' { + '8' + } else { + char::from_u32(u32::from(last_digit) + 1) + .ok_or_else(|| anyhow!("Python version does not end in a digit"))? + }); + + let absolute = std::path::absolute(&base_executable)?; + let canonical = canonicalize_executable(&absolute)?; + let cache_entry = cache.entry( + CacheBucket::Interpreter, + cache_digest(&( + std::env::consts::ARCH, + OsType::from_env() + .map(|operating_system| operating_system.to_string()) + .unwrap_or_default(), + OsRelease::from_env() + .map(|release| release.to_string()) + .unwrap_or_default(), + )), + format!("{}.msgpack", cache_digest(&(&absolute, &canonical))), + ); + + // Replace only exact MessagePack strings, preserving path fields and the executable ctime. + let mut encoded_version = vec![0xa0 | u8::try_from(actual_version.len())?]; + encoded_version.extend_from_slice(actual_version.as_bytes()); + + let mut metadata = fs_err::read(cache_entry.path())?; + let mut offset = 0; + let mut replacements = 0; + while let Some(index) = metadata[offset..] + .windows(encoded_version.len()) + .position(|window| window == encoded_version) + { + let start = offset + index + 1; + metadata[start..start + actual_version.len()].copy_from_slice(stale_version.as_bytes()); + offset = start + actual_version.len(); + replacements += 1; + } + assert!( + replacements >= 2, + "expected to replace the implementation and Python version markers" + ); + + fs_err::write(cache_entry.path(), metadata)?; + environment.set_pyvenv_cfg("version_info", &stale_version)?; + + assert_eq!( + Interpreter::query(&base_executable, &cache)? + .python_version() + .to_string(), + stale_version, + "expected the base interpreter cache to contain the stale version" + ); + assert_eq!( + PythonEnvironment::from_root(&context.venv, &cache)? + .interpreter() + .python_version() + .to_string(), + actual_version, + "expected the virtual environment cache to retain the actual version" + ); + + Ok((base_executable, actual_version, stale_version)) +} + +/// A stale base-interpreter cache must heal before recreating an incompatible environment. +#[test] +fn sync_refreshes_stale_base_interpreter_cache() -> Result<()> { + let context = uv_test::test_context!("3.12"); + context + .temp_dir + .child("pyproject.toml") + .write_str(indoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [] + "#})?; + context + .temp_dir + .child(".python-version") + .write_str("3.12")?; + + let (base_executable, actual_version, _) = stale_base_interpreter_cache(&context)?; + let search_path = base_executable + .parent() + .ok_or_else(|| anyhow!("base interpreter has no parent directory"))?; + + uv_snapshot!(context.filters(), context.sync().env(EnvVars::UV_PYTHON_SEARCH_PATH, search_path), @" + exit_code: 0 (success) + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Removed virtual environment at: .venv + Creating virtual environment at: .venv + Resolved 1 package in [TIME] + Checked in [TIME] + "); + + let cache = Cache::from_path(context.cache_dir.path()); + assert_eq!( + Interpreter::query(&base_executable, &cache)? + .python_version() + .to_string(), + actual_version, + "expected the refreshed base interpreter version to be persisted" + ); + let pyvenv_configuration = context.read(".venv/pyvenv.cfg"); + let pyvenv_version = pyvenv_configuration + .lines() + .find_map(|line| line.strip_prefix("version_info = ")) + .ok_or_else(|| anyhow!("virtual environment has no Python version"))?; + assert_eq!( + pyvenv_version, actual_version, + "expected pyvenv.cfg to contain the actual Python version" + ); + + uv_snapshot!(context.filters(), context.sync().env(EnvVars::UV_PYTHON_SEARCH_PATH, search_path), @" + exit_code: 0 (success) + ----- stderr ----- + Resolved 1 package in [TIME] + Checked in [TIME] + "); + + Ok(()) +} + +/// Stale base-interpreter markers must not satisfy an incompatible Python requirement. +#[test] +fn sync_stale_base_interpreter_cache_respects_requires_python() -> Result<()> { + let context = uv_test::test_context!("3.12"); + let (base_executable, _, stale_version) = stale_base_interpreter_cache(&context)?; + let search_path = base_executable + .parent() + .ok_or_else(|| anyhow!("base interpreter has no parent directory"))?; + + context + .temp_dir + .child("pyproject.toml") + .write_str(&formatdoc! {r#" + [project] + name = "project" + version = "0.1.0" + requires-python = "=={stale_version}" + dependencies = [] + "#})?; + context + .temp_dir + .child(".python-version") + .write_str("3.12")?; + + uv_snapshot!(context.filters(), context.sync().env(EnvVars::UV_PYTHON_SEARCH_PATH, search_path), @" + exit_code: 2 (failure) + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + error: The Python request from `.python-version` resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `==3.12.[X]` (from `project.requires-python`) + Use `uv python pin` to update the `.python-version` file to a compatible version + "); + + Ok(()) +} + // Test that we recreate a virtual environment when `pyvenv.cfg` version // is incompatible with the interpreter version. #[test] From 9c0143dc065879fd28925563bcfb0bdfea10f466 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 12 Aug 2026 10:30:29 -0500 Subject: [PATCH 2/4] Simplify interpreter cache refresh coverage --- crates/uv-python/src/interpreter.rs | 87 +++++++++++-- crates/uv/src/commands/project/mod.rs | 2 +- crates/uv/tests/sync/sync.rs | 181 -------------------------- 3 files changed, 74 insertions(+), 196 deletions(-) diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index 97b5795c93c..8816d7d537f 100644 --- a/crates/uv-python/src/interpreter.rs +++ b/crates/uv-python/src/interpreter.rs @@ -69,15 +69,16 @@ pub struct Interpreter { impl Interpreter { /// Detect the interpreter info for the given Python executable. pub fn query(executable: impl AsRef, cache: &Cache) -> Result { - Self::query_with_cache(executable.as_ref(), cache, false) + Self::query_with_cache(executable, cache, false) } - /// Query the interpreter directly and update its cached metadata. - pub fn query_fresh(executable: impl AsRef, cache: &Cache) -> Result { - Self::query_with_cache(executable.as_ref(), cache, true) - } - - fn query_with_cache(executable: &Path, cache: &Cache, refresh: bool) -> Result { + /// Detect interpreter info, optionally refreshing its cached metadata. + pub fn query_with_cache( + executable: impl AsRef, + cache: &Cache, + refresh: bool, + ) -> Result { + let executable = executable.as_ref(); let info = InterpreterInfo::query_cached(executable, cache, refresh)?; debug_assert!( @@ -1352,8 +1353,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}; @@ -1362,12 +1365,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": { @@ -1425,7 +1424,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(), ); @@ -1486,4 +1492,57 @@ mod tests { "queried\nqueried\n" ); } + + #[tokio::test] + async fn test_cache_refresh_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 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 + cat "{}" + "#, 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" + ); + assert_eq!( + Interpreter::query_with_cache(&mocked_interpreter, &cache, true)?.python_version(), + &updated_version, + "refreshing should query the interpreter despite its unchanged executable" + ); + assert_eq!( + Interpreter::query(&mocked_interpreter, &cache)?.python_version(), + &updated_version, + "refreshing should persist the updated interpreter metadata" + ); + + Ok(()) + } } diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index be719118ef0..7c5ad5c7d9f 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1164,7 +1164,7 @@ fn discover_project_environment( base_interpreter.python_version(), environment.interpreter().python_version(), ); - Interpreter::query_fresh(&base_executable, cache)?; + Interpreter::query_with_cache(&base_executable, cache, true)?; } match compatibility { diff --git a/crates/uv/tests/sync/sync.rs b/crates/uv/tests/sync/sync.rs index 431853c1eeb..44107a37eef 100644 --- a/crates/uv/tests/sync/sync.rs +++ b/crates/uv/tests/sync/sync.rs @@ -5,7 +5,6 @@ use indoc::{formatdoc, indoc}; use insta::assert_snapshot; use predicates::prelude::predicate; use serde_json::json; -use std::path::PathBuf; #[cfg(feature = "test-git")] use std::process::Command; use tempfile::tempdir_in; @@ -13,11 +12,7 @@ use url::Url; use wiremock::matchers::{basic_auth, body_string_contains, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; -use uv_cache::{Cache, CacheBucket}; -use uv_cache_key::cache_digest; use uv_fs::Simplified; -use uv_platform::{OsRelease, OsType}; -use uv_python::{Interpreter, PythonEnvironment, canonicalize_executable}; use uv_static::EnvVars; use uv_test::packse::PackseServer; @@ -12700,182 +12695,6 @@ fn sync_workspace_member_build_constraints() -> Result<()> { Ok(()) } -/// Seed contradictory metadata for the virtual environment and its underlying interpreter. -fn stale_base_interpreter_cache(context: &TestContext) -> Result<(PathBuf, String, String)> { - let cache = Cache::from_path(context.cache_dir.path()); - let environment = PythonEnvironment::from_root(&context.venv, &cache)?; - let base_executable = environment.interpreter().to_base_python()?; - let actual_version = environment.interpreter().python_version().to_string(); - - Interpreter::query(&base_executable, &cache)?; - - let mut stale_version = actual_version.clone(); - let last_digit = stale_version - .pop() - .ok_or_else(|| anyhow!("Python version is empty"))?; - stale_version.push(if last_digit == '9' { - '8' - } else { - char::from_u32(u32::from(last_digit) + 1) - .ok_or_else(|| anyhow!("Python version does not end in a digit"))? - }); - - let absolute = std::path::absolute(&base_executable)?; - let canonical = canonicalize_executable(&absolute)?; - let cache_entry = cache.entry( - CacheBucket::Interpreter, - cache_digest(&( - std::env::consts::ARCH, - OsType::from_env() - .map(|operating_system| operating_system.to_string()) - .unwrap_or_default(), - OsRelease::from_env() - .map(|release| release.to_string()) - .unwrap_or_default(), - )), - format!("{}.msgpack", cache_digest(&(&absolute, &canonical))), - ); - - // Replace only exact MessagePack strings, preserving path fields and the executable ctime. - let mut encoded_version = vec![0xa0 | u8::try_from(actual_version.len())?]; - encoded_version.extend_from_slice(actual_version.as_bytes()); - - let mut metadata = fs_err::read(cache_entry.path())?; - let mut offset = 0; - let mut replacements = 0; - while let Some(index) = metadata[offset..] - .windows(encoded_version.len()) - .position(|window| window == encoded_version) - { - let start = offset + index + 1; - metadata[start..start + actual_version.len()].copy_from_slice(stale_version.as_bytes()); - offset = start + actual_version.len(); - replacements += 1; - } - assert!( - replacements >= 2, - "expected to replace the implementation and Python version markers" - ); - - fs_err::write(cache_entry.path(), metadata)?; - environment.set_pyvenv_cfg("version_info", &stale_version)?; - - assert_eq!( - Interpreter::query(&base_executable, &cache)? - .python_version() - .to_string(), - stale_version, - "expected the base interpreter cache to contain the stale version" - ); - assert_eq!( - PythonEnvironment::from_root(&context.venv, &cache)? - .interpreter() - .python_version() - .to_string(), - actual_version, - "expected the virtual environment cache to retain the actual version" - ); - - Ok((base_executable, actual_version, stale_version)) -} - -/// A stale base-interpreter cache must heal before recreating an incompatible environment. -#[test] -fn sync_refreshes_stale_base_interpreter_cache() -> Result<()> { - let context = uv_test::test_context!("3.12"); - context - .temp_dir - .child("pyproject.toml") - .write_str(indoc! {r#" - [project] - name = "project" - version = "0.1.0" - requires-python = ">=3.12" - dependencies = [] - "#})?; - context - .temp_dir - .child(".python-version") - .write_str("3.12")?; - - let (base_executable, actual_version, _) = stale_base_interpreter_cache(&context)?; - let search_path = base_executable - .parent() - .ok_or_else(|| anyhow!("base interpreter has no parent directory"))?; - - uv_snapshot!(context.filters(), context.sync().env(EnvVars::UV_PYTHON_SEARCH_PATH, search_path), @" - exit_code: 0 (success) - ----- stderr ----- - Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] - Removed virtual environment at: .venv - Creating virtual environment at: .venv - Resolved 1 package in [TIME] - Checked in [TIME] - "); - - let cache = Cache::from_path(context.cache_dir.path()); - assert_eq!( - Interpreter::query(&base_executable, &cache)? - .python_version() - .to_string(), - actual_version, - "expected the refreshed base interpreter version to be persisted" - ); - let pyvenv_configuration = context.read(".venv/pyvenv.cfg"); - let pyvenv_version = pyvenv_configuration - .lines() - .find_map(|line| line.strip_prefix("version_info = ")) - .ok_or_else(|| anyhow!("virtual environment has no Python version"))?; - assert_eq!( - pyvenv_version, actual_version, - "expected pyvenv.cfg to contain the actual Python version" - ); - - uv_snapshot!(context.filters(), context.sync().env(EnvVars::UV_PYTHON_SEARCH_PATH, search_path), @" - exit_code: 0 (success) - ----- stderr ----- - Resolved 1 package in [TIME] - Checked in [TIME] - "); - - Ok(()) -} - -/// Stale base-interpreter markers must not satisfy an incompatible Python requirement. -#[test] -fn sync_stale_base_interpreter_cache_respects_requires_python() -> Result<()> { - let context = uv_test::test_context!("3.12"); - let (base_executable, _, stale_version) = stale_base_interpreter_cache(&context)?; - let search_path = base_executable - .parent() - .ok_or_else(|| anyhow!("base interpreter has no parent directory"))?; - - context - .temp_dir - .child("pyproject.toml") - .write_str(&formatdoc! {r#" - [project] - name = "project" - version = "0.1.0" - requires-python = "=={stale_version}" - dependencies = [] - "#})?; - context - .temp_dir - .child(".python-version") - .write_str("3.12")?; - - uv_snapshot!(context.filters(), context.sync().env(EnvVars::UV_PYTHON_SEARCH_PATH, search_path), @" - exit_code: 2 (failure) - ----- stderr ----- - Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] - error: The Python request from `.python-version` resolved to Python 3.12.[X], which is incompatible with the project's Python requirement: `==3.12.[X]` (from `project.requires-python`) - Use `uv python pin` to update the `.python-version` file to a compatible version - "); - - Ok(()) -} - // Test that we recreate a virtual environment when `pyvenv.cfg` version // is incompatible with the interpreter version. #[test] From ac4a931167ea5c3e8194e574b384db4d86a8e6ec Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 12 Aug 2026 10:36:33 -0500 Subject: [PATCH 3/4] Simplify interpreter cache corruption comment --- crates/uv/src/commands/project/mod.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 7c5ad5c7d9f..f25e9cab6a3 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1147,9 +1147,8 @@ fn discover_project_environment( cache, ); - // A launcher's shared library can change without updating the executable timestamp used to - // invalidate its cache. If the environment reveals conflicting versions for the same base - // interpreter, refresh that entry before interpreter discovery can select stale metadata. + // Conflicting versions for the same base interpreter indicate its cached metadata may be + // corrupted. Refresh the entry before interpreter discovery can select stale metadata. if matches!( &compatibility, Err(EnvironmentIncompatibilityError::PyenvVersionConflict(..)) From 034fca1b56dca38ada0fa8f76205af2c135dd866 Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 12 Aug 2026 10:47:51 -0500 Subject: [PATCH 4/4] Clear stale interpreter cache entries before discovery --- crates/uv-python/src/interpreter.rs | 126 +++++++++++++++----------- crates/uv/src/commands/project/mod.rs | 6 +- 2 files changed, 77 insertions(+), 55 deletions(-) diff --git a/crates/uv-python/src/interpreter.rs b/crates/uv-python/src/interpreter.rs index 8816d7d537f..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,17 +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 { - Self::query_with_cache(executable, cache, false) - } - - /// Detect interpreter info, optionally refreshing its cached metadata. - pub fn query_with_cache( - executable: impl AsRef, - cache: &Cache, - refresh: bool, - ) -> Result { let executable = executable.as_ref(); - let info = InterpreterInfo::query_cached(executable, cache, refresh)?; + let info = InterpreterInfo::query_cached(executable, cache)?; debug_assert!( info.sys_executable.is_absolute(), @@ -111,6 +102,19 @@ impl Interpreter { }) } + /// 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 { @@ -1127,12 +1131,47 @@ 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 /// unless the Python executable changes, so we use the executable's last modified /// time as a cache key. - fn query_cached(executable: &Path, cache: &Cache, refresh: bool) -> Result { + fn query_cached(executable: &Path, cache: &Cache) -> Result { let absolute = std::path::absolute(executable)?; // Provide a better error message if the link is broken or the file does not exist. Since @@ -1160,47 +1199,16 @@ 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. let modified = Timestamp::from_path(canonical).map_err(handle_io_error)?; // Read from the cache. - if !refresh - && cache - .freshness(&cache_entry, None, None) - .is_ok_and(Freshness::is_fresh) + if cache + .freshness(&cache_entry, None, None) + .is_ok_and(Freshness::is_fresh) { if let Ok(data) = fs::read(cache_entry.path()) { match rmp_serde::from_slice::>(&data) { @@ -1494,10 +1502,11 @@ mod tests { } #[tokio::test] - async fn test_cache_refresh_with_unchanged_executable() -> Result<()> { + 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)?; @@ -1506,8 +1515,9 @@ mod tests { &mocked_interpreter, formatdoc! {r#" #!/bin/sh + printf '.' >> "{}" cat "{}" - "#, response_file.display()}, + "#, query_count.display(), response_file.display()}, )?; fs::set_permissions( &mocked_interpreter, @@ -1532,15 +1542,27 @@ mod tests { &original_version, "an unchanged executable should retain its cached interpreter metadata" ); + + Interpreter::clear_cache(&mocked_interpreter, &cache)?; assert_eq!( - Interpreter::query_with_cache(&mocked_interpreter, &cache, true)?.python_version(), + 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, - "refreshing should query the interpreter despite its unchanged executable" + "clearing the cache should force the next query to run the interpreter" ); assert_eq!( Interpreter::query(&mocked_interpreter, &cache)?.python_version(), &updated_version, - "refreshing should persist the updated interpreter metadata" + "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 f25e9cab6a3..d2f0ebce95b 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -1148,7 +1148,7 @@ fn discover_project_environment( ); // Conflicting versions for the same base interpreter indicate its cached metadata may be - // corrupted. Refresh the entry before interpreter discovery can select stale metadata. + // corrupted. Clear the entry before interpreter discovery can select stale metadata. if matches!( &compatibility, Err(EnvironmentIncompatibilityError::PyenvVersionConflict(..)) @@ -1158,12 +1158,12 @@ fn discover_project_environment( && environment.interpreter().python_version() != base_interpreter.python_version() { debug!( - "Refreshing cached interpreter info for {} after finding conflicting Python versions ({} and {})", + "Clearing cached interpreter info for {} after finding conflicting Python versions ({} and {})", base_executable.user_display(), base_interpreter.python_version(), environment.interpreter().python_version(), ); - Interpreter::query_with_cache(&base_executable, cache, true)?; + Interpreter::clear_cache(&base_executable, cache)?; } match compatibility {