Skip to content
Closed
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
43 changes: 40 additions & 3 deletions crates/uv-python/src/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub enum InvalidEnvironmentKind {
NotDirectory,
Empty,
MissingExecutable(PathBuf),
RelocatedFrom(Option<PathBuf>),
}

impl From<PythonNotFound> for EnvironmentNotFound {
Expand Down Expand Up @@ -130,6 +131,12 @@ impl fmt::Display for InvalidEnvironmentKind {
Self::MissingExecutable(path) => {
write!(f, "missing Python executable at `{}`", path.user_display())
}
Self::RelocatedFrom(Some(path)) => {
write!(f, "relocated from `{}`", path.user_display())
}
Self::RelocatedFrom(None) => {
write!(f, "missing uv-venv-path in pyvenv.cfg")
}
Self::Empty => write!(f, "directory is empty"),
}
}
Expand Down Expand Up @@ -212,11 +219,41 @@ impl PythonEnvironment {
};

let interpreter = Interpreter::query(executable, cache)?;

Ok(Self(Arc::new(PythonEnvironmentShared {
let env = Self(Arc::new(PythonEnvironmentShared {
root: interpreter.sys_prefix().to_path_buf(),
interpreter,
})))
}));

// Check if the environment is relocatable and if it was relocated from a different path.
match env.cfg() {
Ok(cfg) if !cfg.is_relocatable() => {
if let Some(uv_venv_path) = cfg.uv_venv_path.as_ref() {
if std::path::absolute(root.as_ref())
.ok()
.map(|p| p.simplified() != uv_venv_path)
.unwrap_or(true)
{
return Err(InvalidEnvironment {
path: root.as_ref().to_path_buf(),
kind: InvalidEnvironmentKind::RelocatedFrom(Some(uv_venv_path.clone())),
}
.into());
}
} else if cfg.uv {
return Err(InvalidEnvironment {
path: root.as_ref().to_path_buf(),
kind: InvalidEnvironmentKind::RelocatedFrom(None),
}
.into());
}
}
// System Python environments don't have a `pyvenv.cfg` file.
// However we should consider what to do if the file exists but some
// other error occurred while parsing it. For now, continue with previous
// behaviour (which did not consider the file's existence at this stage).
_ => {}
};
Ok(env)
}

/// Create a [`PythonEnvironment`] from an existing [`PythonInstallation`].
Expand Down
7 changes: 7 additions & 0 deletions crates/uv-python/src/virtualenv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ pub struct PyVenvConfiguration {
pub(crate) uv: bool,
/// Is the virtual environment relocatable?
pub(crate) relocatable: bool,
/// At which root was the environment originally created?
pub(crate) uv_venv_path: Option<PathBuf>,
/// Was the virtual environment populated with seed packages?
pub(crate) seed: bool,
}
Expand Down Expand Up @@ -185,6 +187,7 @@ impl PyVenvConfiguration {
let mut uv = false;
let mut relocatable = false;
let mut seed = false;
let mut uv_venv_path = None;

// Per https://snarky.ca/how-virtual-environments-work/, the `pyvenv.cfg` file is not a
// valid INI file, and is instead expected to be parsed by partitioning each line on the
Expand All @@ -208,6 +211,9 @@ impl PyVenvConfiguration {
"seed" => {
seed = value.trim().to_lowercase() == "true";
}
"uv-venv-path" => {
uv_venv_path = Some(PathBuf::from(value.trim()));
}
_ => {}
}
}
Expand All @@ -216,6 +222,7 @@ impl PyVenvConfiguration {
virtualenv,
uv,
relocatable,
uv_venv_path,
seed,
})
}
Expand Down
5 changes: 5 additions & 0 deletions crates/uv-virtualenv/src/virtualenv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,11 @@ pub(crate) fn create(

if relocatable {
pyvenv_cfg_data.push(("relocatable".to_string(), "true".to_string()));
} else {
pyvenv_cfg_data.push((
"uv-venv-path".to_string(),
location.simplified().to_str().unwrap().to_string(),
));
}

if seed {
Expand Down
2 changes: 2 additions & 0 deletions crates/uv/src/commands/project/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,8 @@ impl ProjectInterpreter {
}
// If the environment is an empty directory, it's fine to use
InvalidEnvironmentKind::Empty => {}
// If it's "only" relocated, it is also fine to use (will be deleted later)
InvalidEnvironmentKind::RelocatedFrom(_) => {}
};
}
Err(uv_python::Error::Query(uv_python::InterpreterError::NotFound(path))) => {
Expand Down
12 changes: 8 additions & 4 deletions crates/uv/tests/it/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3351,7 +3351,7 @@ fn run_gui_script_explicit_unix() -> Result<()> {

#[test]
#[cfg(unix)]
fn run_linked_environment_path() -> Result<()> {
fn run_replace_linked_environment_path() -> Result<()> {
use anyhow::Ok;

let context = TestContext::new("3.12")
Expand All @@ -3372,14 +3372,18 @@ fn run_linked_environment_path() -> Result<()> {
// Create a link from `target` -> virtual environment
fs_err::os::unix::fs::symlink(&context.venv, context.temp_dir.child("target"))?;

// Running `uv sync` should use the environment at `target``
// Running `uv sync` recreates the target because the existing venv is not relocatable
// and is bound to a different location.
uv_snapshot!(context.filters(), context.sync()
.env(EnvVars::UV_PROJECT_ENVIRONMENT, "target"), @r###"
.env(EnvVars::UV_PROJECT_ENVIRONMENT, "target"), @r"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Removed virtual environment at: target
Creating virtual environment at: target
Resolved 8 packages in [TIME]
Prepared 6 packages in [TIME]
Installed 6 packages in [TIME]
Expand All @@ -3389,7 +3393,7 @@ fn run_linked_environment_path() -> Result<()> {
+ packaging==24.0
+ pathspec==0.12.1
+ platformdirs==4.2.0
"###);
");

// `sys.prefix` and `sys.executable` should be from the `target` directory
uv_snapshot!(context.filters(), context.run()
Expand Down
52 changes: 52 additions & 0 deletions crates/uv/tests/it/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use assert_cmd::prelude::*;
use assert_fs::{fixture::ChildPath, prelude::*};
use indoc::{formatdoc, indoc};
use insta::assert_snapshot;
use itertools::Itertools;

use crate::common::{download_to_disk, uv_snapshot, venv_bin_path, TestContext};
use predicates::prelude::predicate;
Expand Down Expand Up @@ -4401,6 +4402,57 @@ fn sync_invalid_environment() -> Result<()> {
+ iniconfig==2.0.0
"###);

// Similarly, if the venv is invalid due to being relocated...
let cfg_p = context.temp_dir.join(".venv").join("pyvenv.cfg");
let cfg = fs_err::read_to_string(&cfg_p)?;
let alt_venv_line = "uv-venv-path = /not/the/right/path";
fs_err::write(
&cfg_p,
cfg.lines()
.map(|line| {
if line.starts_with("uv-venv-path = ") {
alt_venv_line
} else {
line
}
})
.join("\n"),
)?;
// We should still delete and recreate
uv_snapshot!(context.filters(), context.sync(), @r###"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Removed virtual environment at: .venv
Creating virtual environment at: .venv
Resolved 2 packages in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
"###);
// A missing uv-venv-path also counts as a relocated venv...
fs_err::write(
&cfg_p,
cfg.lines()
.filter(|&l| !l.starts_with("uv-venv-path"))
.join("\n"),
)?;
uv_snapshot!(context.filters(), context.sync(), @r###"
success: true
exit_code: 0
----- stdout -----

----- stderr -----
Using CPython 3.12.[X] interpreter at: [PYTHON-3.12]
Removed virtual environment at: .venv
Creating virtual environment at: .venv
Resolved 2 packages in [TIME]
Installed 1 package in [TIME]
+ iniconfig==2.0.0
"###);

let bin = venv_bin_path(context.temp_dir.join(".venv"));

// If there's just a broken symlink, we should warn
Expand Down
12 changes: 6 additions & 6 deletions crates/uv/tests/it/tool_upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -740,8 +740,8 @@ fn tool_upgrade_python() {
filters => context.filters(),
}, {
let content = fs_err::read_to_string(tool_dir.join("babel").join("pyvenv.cfg")).unwrap();
let lines: Vec<&str> = content.split('\n').collect();
assert_snapshot!(lines[lines.len() - 3], @r###"
let version_info_line = content.split('\n').find(|l| l.starts_with("version_info =")).unwrap();
assert_snapshot!(version_info_line, @r###"
version_info = 3.12.[X]
"###);
});
Expand Down Expand Up @@ -825,8 +825,8 @@ fn tool_upgrade_python_with_all() {
filters => context.filters(),
}, {
let content = fs_err::read_to_string(tool_dir.join("babel").join("pyvenv.cfg")).unwrap();
let lines: Vec<&str> = content.split('\n').collect();
assert_snapshot!(lines[lines.len() - 3], @r###"
let version_info_line = content.split('\n').find(|l| l.starts_with("version_info =")).unwrap();
assert_snapshot!(version_info_line, @r###"
version_info = 3.12.[X]
"###);
});
Expand All @@ -835,8 +835,8 @@ fn tool_upgrade_python_with_all() {
filters => context.filters(),
}, {
let content = fs_err::read_to_string(tool_dir.join("python-dotenv").join("pyvenv.cfg")).unwrap();
let lines: Vec<&str> = content.split('\n').collect();
assert_snapshot!(lines[lines.len() - 3], @r###"
let version_info_line = content.split('\n').find(|l| l.starts_with("version_info =")).unwrap();
assert_snapshot!(version_info_line, @r###"
version_info = 3.12.[X]
"###);
});
Expand Down
8 changes: 8 additions & 0 deletions crates/uv/tests/it/venv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,11 @@ fn verify_pyvenv_cfg() {

// Not relocatable by default.
pyvenv_cfg.assert(predicates::str::contains("relocatable").not());

// The location of the virtual environment is saved
let path = std::path::absolute(context.venv.path()).unwrap();
let path = path.to_str().unwrap();
pyvenv_cfg.assert(predicates::str::contains(format!("uv-venv-path = {path}")));
}

#[test]
Expand All @@ -987,6 +992,9 @@ fn verify_pyvenv_cfg_relocatable() {
// Relocatable flag is set.
pyvenv_cfg.assert(predicates::str::contains("relocatable = true"));

// Relocatable pyvenv.cfg does not have path saved
pyvenv_cfg.assert(predicates::str::contains("uv-venv-path").not());

// Activate scripts contain the relocatable boilerplate
let scripts = if cfg!(windows) {
context.venv.child("Scripts")
Expand Down