Skip to content

Commit d9a8a05

Browse files
committed
Clear stale interpreter cache entries before discovery
1 parent acdf50d commit d9a8a05

2 files changed

Lines changed: 68 additions & 47 deletions

File tree

crates/uv-python/src/interpreter.rs

Lines changed: 65 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use serde::{Deserialize, Serialize};
1515
use thiserror::Error;
1616
use tracing::{debug, trace, warn};
1717

18-
use uv_cache::{Cache, CacheBucket, CachedByTimestamp, Freshness};
18+
use uv_cache::{Cache, CacheBucket, CacheEntry, CachedByTimestamp, Freshness};
1919
use uv_cache_info::Timestamp;
2020
use uv_cache_key::cache_digest;
2121
use uv_fs::{
@@ -68,17 +68,8 @@ pub struct Interpreter {
6868
impl Interpreter {
6969
/// Detect the interpreter info for the given Python executable.
7070
pub fn query(executable: impl AsRef<Path>, cache: &Cache) -> Result<Self, Error> {
71-
Self::query_with_cache(executable, cache, false)
72-
}
73-
74-
/// Detect interpreter info, optionally refreshing its cached metadata.
75-
pub fn query_with_cache(
76-
executable: impl AsRef<Path>,
77-
cache: &Cache,
78-
refresh: bool,
79-
) -> Result<Self, Error> {
8071
let executable = executable.as_ref();
81-
let info = InterpreterInfo::query_cached(executable, cache, refresh)?;
72+
let info = InterpreterInfo::query_cached(executable, cache)?;
8273

8374
debug_assert!(
8475
info.sys_executable.is_absolute(),
@@ -110,6 +101,19 @@ impl Interpreter {
110101
})
111102
}
112103

104+
/// Remove any cached metadata for the given Python executable.
105+
pub fn clear_cache(executable: impl AsRef<Path>, cache: &Cache) -> Result<(), Error> {
106+
let absolute = std::path::absolute(executable.as_ref())?;
107+
let canonical = canonicalize_executable(&absolute)?;
108+
let cache_entry = InterpreterInfo::cache_entry(&absolute, &canonical, cache);
109+
110+
match fs::remove_file(cache_entry.path()) {
111+
Ok(()) => Ok(()),
112+
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
113+
Err(err) => Err(err.into()),
114+
}
115+
}
116+
113117
/// Return a new [`Interpreter`] with the given virtual environment root.
114118
#[must_use]
115119
pub fn with_virtualenv(self, virtualenv: VirtualEnvironment) -> Self {
@@ -1126,12 +1130,38 @@ impl InterpreterInfo {
11261130
Ok(())
11271131
}
11281132

1133+
/// Return the cache entry for an interpreter's absolute and canonical executable paths.
1134+
fn cache_entry(absolute: &Path, canonical: &Path, cache: &Cache) -> CacheEntry {
1135+
cache.entry(
1136+
CacheBucket::Interpreter,
1137+
// Shard interpreter metadata by host architecture, operating system, and version, to
1138+
// invalidate the cache (e.g.) on OS upgrades.
1139+
cache_digest(&(
1140+
ARCH,
1141+
uv_platform::OsType::from_env()
1142+
.map(|os_type| os_type.to_string())
1143+
.unwrap_or_default(),
1144+
uv_platform::OsRelease::from_env()
1145+
.map(|os_release| os_release.to_string())
1146+
.unwrap_or_default(),
1147+
)),
1148+
// We use the absolute path for the cache entry to avoid cache collisions for relative
1149+
// paths. But we don't want to query the executable with symbolic links resolved because
1150+
// that can change reported values, e.g., `sys.executable`. We include the canonical
1151+
// path in the cache entry as well, otherwise we can have cache collisions if an
1152+
// absolute path refers to different interpreters with matching ctimes, e.g., if you
1153+
// have a `.venv/bin/python` pointing to both Python 3.12 and Python 3.13 that were
1154+
// modified at the same time.
1155+
format!("{}.msgpack", cache_digest(&(absolute, canonical))),
1156+
)
1157+
}
1158+
11291159
/// A wrapper around [`markers::query_interpreter_info`] to cache the computed markers.
11301160
///
11311161
/// Running a Python script is (relatively) expensive, and the markers won't change
11321162
/// unless the Python executable changes, so we use the executable's last modified
11331163
/// time as a cache key.
1134-
fn query_cached(executable: &Path, cache: &Cache, refresh: bool) -> Result<Self, Error> {
1164+
fn query_cached(executable: &Path, cache: &Cache) -> Result<Self, Error> {
11351165
let absolute = std::path::absolute(executable)?;
11361166

11371167
// Provide a better error message if the link is broken or the file does not exist. Since
@@ -1159,39 +1189,16 @@ impl InterpreterInfo {
11591189
};
11601190

11611191
let canonical = canonicalize_executable(&absolute).map_err(handle_io_error)?;
1162-
1163-
let cache_entry = cache.entry(
1164-
CacheBucket::Interpreter,
1165-
// Shard interpreter metadata by host architecture, operating system, and version, to
1166-
// invalidate the cache (e.g.) on OS upgrades.
1167-
cache_digest(&(
1168-
ARCH,
1169-
uv_platform::OsType::from_env()
1170-
.map(|os_type| os_type.to_string())
1171-
.unwrap_or_default(),
1172-
uv_platform::OsRelease::from_env()
1173-
.map(|os_release| os_release.to_string())
1174-
.unwrap_or_default(),
1175-
)),
1176-
// We use the absolute path for the cache entry to avoid cache collisions for relative
1177-
// paths. But we don't want to query the executable with symbolic links resolved because
1178-
// that can change reported values, e.g., `sys.executable`. We include the canonical
1179-
// path in the cache entry as well, otherwise we can have cache collisions if an
1180-
// absolute path refers to different interpreters with matching ctimes, e.g., if you
1181-
// have a `.venv/bin/python` pointing to both Python 3.12 and Python 3.13 that were
1182-
// modified at the same time.
1183-
format!("{}.msgpack", cache_digest(&(&absolute, &canonical))),
1184-
);
1192+
let cache_entry = Self::cache_entry(&absolute, &canonical, cache);
11851193

11861194
// We check the timestamp of the canonicalized executable to check if an underlying
11871195
// interpreter has been modified.
11881196
let modified = Timestamp::from_path(canonical).map_err(handle_io_error)?;
11891197

11901198
// Read from the cache.
1191-
if !refresh
1192-
&& cache
1193-
.freshness(&cache_entry, None, None)
1194-
.is_ok_and(Freshness::is_fresh)
1199+
if cache
1200+
.freshness(&cache_entry, None, None)
1201+
.is_ok_and(Freshness::is_fresh)
11951202
{
11961203
if let Ok(data) = fs::read(cache_entry.path()) {
11971204
match rmp_serde::from_slice::<CachedByTimestamp<Self>>(&data) {
@@ -1485,10 +1492,11 @@ mod tests {
14851492
}
14861493

14871494
#[tokio::test]
1488-
async fn test_cache_refresh_with_unchanged_executable() -> Result<()> {
1495+
async fn test_cache_eviction_with_unchanged_executable() -> Result<()> {
14891496
let mock_dir = tempdir()?;
14901497
let mocked_interpreter = mock_dir.path().join("python");
14911498
let response_file = mock_dir.path().join("response.json");
1499+
let query_count = mock_dir.path().join("queries");
14921500

14931501
let mut response = serde_json::from_str::<Value>(mocked_interpreter_response())?;
14941502
response["sys_executable"] = serde_json::to_value(&mocked_interpreter)?;
@@ -1497,8 +1505,9 @@ mod tests {
14971505
&mocked_interpreter,
14981506
formatdoc! {r#"
14991507
#!/bin/sh
1508+
printf '.' >> "{}"
15001509
cat "{}"
1501-
"#, response_file.display()},
1510+
"#, query_count.display(), response_file.display()},
15021511
)?;
15031512
fs::set_permissions(
15041513
&mocked_interpreter,
@@ -1523,15 +1532,27 @@ mod tests {
15231532
&original_version,
15241533
"an unchanged executable should retain its cached interpreter metadata"
15251534
);
1535+
1536+
Interpreter::clear_cache(&mocked_interpreter, &cache)?;
1537+
assert_eq!(
1538+
fs::read_to_string(&query_count)?,
1539+
".",
1540+
"clearing cached metadata should not query the interpreter"
1541+
);
15261542
assert_eq!(
1527-
Interpreter::query_with_cache(&mocked_interpreter, &cache, true)?.python_version(),
1543+
Interpreter::query(&mocked_interpreter, &cache)?.python_version(),
15281544
&updated_version,
1529-
"refreshing should query the interpreter despite its unchanged executable"
1545+
"clearing the cache should force the next query to run the interpreter"
15301546
);
15311547
assert_eq!(
15321548
Interpreter::query(&mocked_interpreter, &cache)?.python_version(),
15331549
&updated_version,
1534-
"refreshing should persist the updated interpreter metadata"
1550+
"the next query should persist the updated interpreter metadata"
1551+
);
1552+
assert_eq!(
1553+
fs::read_to_string(&query_count)?,
1554+
"..",
1555+
"the updated interpreter metadata should be cached again"
15351556
);
15361557

15371558
Ok(())

crates/uv/src/commands/project/mod.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1148,7 +1148,7 @@ fn discover_project_environment(
11481148
);
11491149

11501150
// Conflicting versions for the same base interpreter indicate its cached metadata may be
1151-
// corrupted. Refresh the entry before interpreter discovery can select stale metadata.
1151+
// corrupted. Clear the entry before interpreter discovery can select stale metadata.
11521152
if matches!(
11531153
&compatibility,
11541154
Err(EnvironmentIncompatibilityError::PyenvVersionConflict(..))
@@ -1158,12 +1158,12 @@ fn discover_project_environment(
11581158
&& environment.interpreter().python_version() != base_interpreter.python_version()
11591159
{
11601160
debug!(
1161-
"Refreshing cached interpreter info for {} after finding conflicting Python versions ({} and {})",
1161+
"Clearing cached interpreter info for {} after finding conflicting Python versions ({} and {})",
11621162
base_executable.user_display(),
11631163
base_interpreter.python_version(),
11641164
environment.interpreter().python_version(),
11651165
);
1166-
Interpreter::query_with_cache(&base_executable, cache, true)?;
1166+
Interpreter::clear_cache(&base_executable, cache)?;
11671167
}
11681168

11691169
match compatibility {

0 commit comments

Comments
 (0)