Includes sys.prefix in cached environment keys to avoid --with collisions across projects (#14403)

Fixes https://github.com/astral-sh/uv/issues/12889.

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
This commit is contained in:
Jack O'Connor 2025-07-02 12:40:18 -07:00 committed by GitHub
parent a6bb65c78d
commit ec54dce919
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 45 additions and 16 deletions

View file

@ -44,13 +44,15 @@ impl CachedEnvironment {
printer: Printer,
preview: PreviewMode,
) -> Result<Self, ProjectError> {
let interpreter = Self::base_interpreter(interpreter, cache)?;
// Resolve the "base" interpreter, which resolves to an underlying parent interpreter if the
// given interpreter is a virtual environment.
let base_interpreter = Self::base_interpreter(interpreter, cache)?;
// Resolve the requirements with the interpreter.
let resolution = Resolution::from(
resolve_environment(
spec,
&interpreter,
&base_interpreter,
build_constraints.clone(),
&settings.resolver,
network_settings,
@ -73,13 +75,34 @@ impl CachedEnvironment {
hash_digest(&distributions)
};
// Hash the interpreter based on its path.
// TODO(charlie): Come up with a robust hash for the interpreter.
let interpreter_hash =
cache_digest(&canonicalize_executable(interpreter.sys_executable())?);
// Construct a hash for the environment.
//
// Use the canonicalized base interpreter path since that's the interpreter we performed the
// resolution with and the interpreter the environment will be created with.
//
// We also include the canonicalized `sys.prefix` of the non-base interpreter, that is, the
// virtual environment's path. Originally, we shared cached environments independent of the
// environment they'd be layered on top of. However, this causes collisions as the overlay
// `.pth` file can be overridden by another instance of uv. Including this element in the key
// avoids this problem at the cost of creating separate cached environments for identical
// `--with` invocations across projects. We use `sys.prefix` rather than `sys.executable` so
// we can canonicalize it without invalidating the purpose of the element — it'd probably be
// safe to just use the absolute `sys.executable` as well.
//
// TODO(zanieb): Since we're not sharing these environmments across projects, we should move
// [`CachedEvnvironment::set_overlay`] etc. here since the values there should be constant
// now.
//
// TODO(zanieb): We should include the version of the base interpreter in the hash, so if
// the interpreter at the canonicalized path changes versions we construct a new
// environment.
let environment_hash = cache_digest(&(
&canonicalize_executable(base_interpreter.sys_executable())?,
&interpreter.sys_prefix().canonicalize()?,
));
// Search in the content-addressed cache.
let cache_entry = cache.entry(CacheBucket::Environments, interpreter_hash, resolution_hash);
let cache_entry = cache.entry(CacheBucket::Environments, environment_hash, resolution_hash);
if cache.refresh().is_none() {
if let Ok(root) = cache.resolve_link(cache_entry.path()) {
@ -93,7 +116,7 @@ impl CachedEnvironment {
let temp_dir = cache.venv_dir()?;
let venv = uv_virtualenv::create_venv(
temp_dir.path(),
interpreter,
base_interpreter,
uv_virtualenv::Prompt::None,
false,
false,

View file

@ -4777,7 +4777,7 @@ fn run_groups_include_requires_python() -> Result<()> {
bar = ["iniconfig"]
baz = ["iniconfig"]
dev = ["sniffio", {include-group = "foo"}, {include-group = "baz"}]
[tool.uv.dependency-groups]
foo = {requires-python="<3.13"}
@ -4876,7 +4876,7 @@ fn exit_status_signal() -> Result<()> {
#[test]
fn run_repeated() -> Result<()> {
let context = TestContext::new_with_versions(&["3.13"]);
let context = TestContext::new_with_versions(&["3.13", "3.12"]);
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! { r#"
@ -4923,22 +4923,25 @@ fn run_repeated() -> Result<()> {
Resolved 1 package in [TIME]
"###);
// Re-running as a tool shouldn't require reinstalling `typing-extensions`, since the environment is cached.
// Re-running as a tool does require reinstalling `typing-extensions`, since the base venv is
// different.
uv_snapshot!(
context.filters(),
context.tool_run().arg("--with").arg("typing-extensions").arg("python").arg("-c").arg("import typing_extensions; import iniconfig"), @r###"
context.tool_run().arg("--with").arg("typing-extensions").arg("python").arg("-c").arg("import typing_extensions; import iniconfig"), @r#"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
Installed 1 package in [TIME]
+ typing-extensions==4.10.0
Traceback (most recent call last):
File "<string>", line 1, in <module>
import typing_extensions; import iniconfig
^^^^^^^^^^^^^^^^
ModuleNotFoundError: No module named 'iniconfig'
"###);
"#);
Ok(())
}
@ -4979,22 +4982,25 @@ fn run_without_overlay() -> Result<()> {
+ typing-extensions==4.10.0
"###);
// Import `iniconfig` in the context of a `tool run` command, which should fail.
// Import `iniconfig` in the context of a `tool run` command, which should fail. Note that
// typing-extensions gets installed again, because the venv is not shared.
uv_snapshot!(
context.filters(),
context.tool_run().arg("--with").arg("typing-extensions").arg("python").arg("-c").arg("import typing_extensions; import iniconfig"), @r###"
context.tool_run().arg("--with").arg("typing-extensions").arg("python").arg("-c").arg("import typing_extensions; import iniconfig"), @r#"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
Installed 1 package in [TIME]
+ typing-extensions==4.10.0
Traceback (most recent call last):
File "<string>", line 1, in <module>
import typing_extensions; import iniconfig
^^^^^^^^^^^^^^^^
ModuleNotFoundError: No module named 'iniconfig'
"###);
"#);
// Re-running in the context of the project should reset the overlay.
uv_snapshot!(