Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 132 additions & 41 deletions crates/uv-python/src/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -69,7 +69,8 @@ pub struct Interpreter {
impl Interpreter {
/// Detect the interpreter info for the given Python executable.
pub fn query(executable: impl AsRef<Path>, cache: &Cache) -> Result<Self, Error> {
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(),
Expand Down Expand Up @@ -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<Path>, 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 {
Expand Down Expand Up @@ -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 {
Comment thread
zanieb marked this conversation as resolved.
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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -1342,8 +1361,10 @@ fn python_home(interpreter: &Path) -> Option<PathBuf> {
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};
Expand All @@ -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": {
Expand Down Expand Up @@ -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(),
);
Expand Down Expand Up @@ -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::<Value>(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(())
}
}
25 changes: 23 additions & 2 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down