Repository: astral-sh/rye (Rust)

Issue:
rye list overwrites (managed) venv if python version does not match
### Steps to Reproduce

In this case, rye list overwrites the venv. In this test case, the project is rye managed, but it's still surprising that rye list modifies the project.

1. `mkdir project; cd project; rye init -p 3.12; rye sync; rye pin 3.11.7; rye list; cd -`

Using uv 0.3.3

### Expected Result

`rye list` keeps the existing virtualenv

### Actual Result

`rye list` overwrites `.venv` with a new empty venv.

We have tool.rye.managed=true but are still surprised that rye list was destructive.

### Version Info

rye 0.39.0
commit: 0.39.0 (bf3ccf818 2024-08-21)
platform: linux (x86_64)
self-python: cpython@3.12.4
symlink support: true
uv enabled: true

### Stacktrace

_No response_


Discussion:

Patch:
diff --git a/notes/pep508.md b/notes/pep508.md
index f2cf2b1ede..0238c8efb8 100644
--- a/notes/pep508.md
+++ b/notes/pep508.md
@@ -96,7 +96,7 @@ dependencies = [
 ]
 
 [project.dependencies_meta.0]
-exta_information = 42
+extra_information = 42
 ```
 
 Unfortunately that would still cause issues for tools that locally interpret `pyproject.toml`
diff --git a/rye/src/cli/list.rs b/rye/src/cli/list.rs
index bd7c2c46e4..92812e25a3 100644
--- a/rye/src/cli/list.rs
+++ b/rye/src/cli/list.rs
@@ -3,10 +3,9 @@ use std::path::PathBuf;
 use anyhow::Error;
 use clap::Parser;
 
-use crate::bootstrap::ensure_self_venv;
 use crate::pyproject::PyProject;
 use crate::utils::{get_venv_python_bin, CommandOutput};
-use crate::uv::{UvBuilder, UvWithVenv};
+use crate::uv::{UvBuilder, Venv};
 
 /// Prints the currently installed packages.
 #[derive(Parser, Debug)]
@@ -20,23 +19,12 @@ pub fn execute(cmd: Args) -> Result<(), Error> {
     let project = PyProject::load_or_discover(cmd.pyproject.as_deref())?;
     let python = get_venv_python_bin(&project.venv_path());
     if !python.is_file() {
+        warn!("Project is not synced, no virtualenv found. Run `rye sync`.");
         return Ok(());
     }
-    let _ = ensure_self_venv(CommandOutput::Normal)?;
-
     let uv = UvBuilder::new()
         .with_output(CommandOutput::Normal)
         .ensure_exists()?;
-    if !project.rye_managed() {
-        UvWithVenv::new(uv, &project.venv_path(), &project.venv_python_version()?).freeze()?;
-    } else {
-        uv.venv(
-            &project.venv_path(),
-            &python,
-            &project.venv_python_version()?,
-            None,
-        )?
-        .freeze()?;
-    }
+    uv.read_only_venv(&project.venv_path())?.freeze()?;
     Ok(())
 }
diff --git a/rye/src/uv.rs b/rye/src/uv.rs
index d20d0370af..e1d629df60 100644
--- a/rye/src/uv.rs
+++ b/rye/src/uv.rs
@@ -295,23 +295,38 @@ impl Uv {
         cmd
     }
 
-    /// Ensures a venv is exists or is created at the given path.
-    /// Returns a UvWithVenv that can be used to run commands in the venv.
+    /// Ensures a venv exists, creating it at the given path if necessary.
+    ///
+    /// Returns a [`ReadWriteVenv`] that can be used to run commands in the venv.
     pub fn venv(
         &self,
         venv_dir: &Path,
         py_bin: &Path,
         version: &PythonVersion,
         prompt: Option<&str>,
-    ) -> Result<UvWithVenv, Error> {
+    ) -> Result<ReadWriteVenv, Error> {
         match read_venv_marker(venv_dir) {
             Some(venv) if venv.is_compatible(version) => {
-                Ok(UvWithVenv::new(self.clone(), venv_dir, version))
+                Ok(ReadWriteVenv::new(self.clone(), venv_dir, version))
             }
             _ => self.create_venv(venv_dir, py_bin, version, prompt),
         }
     }
 
+    /// Returns a [`ReadOnlyVenv`] that can be used to run commands in the venv.
+    ///
+    /// Returns an error if the venv does not exist.
+    pub fn read_only_venv(&self, venv_dir: &Path) -> Result<ReadOnlyVenv, Error> {
+        if venv_dir.is_dir() {
+            Ok(ReadOnlyVenv::new(self.clone(), venv_dir))
+        } else {
+            Err(anyhow!(
+                "Virtualenv not found at path: {}",
+                venv_dir.display()
+            ))
+        }
+    }
+
     /// Get uv binary path
     ///
     /// Warning: Always use self.cmd() when at all possible
@@ -325,7 +340,7 @@ impl Uv {
         py_bin: &Path,
         version: &PythonVersion,
         prompt: Option<&str>,
-    ) -> Result<UvWithVenv, Error> {
+    ) -> Result<ReadWriteVenv, Error> {
         let mut cmd = self.cmd();
         cmd.arg("venv").arg("--python").arg(py_bin);
         if let Some(prompt) = prompt {
@@ -348,7 +363,7 @@ impl Uv {
                 status
             ));
         }
-        Ok(UvWithVenv::new(self.clone(), venv_dir, version))
+        Ok(ReadWriteVenv::new(self.clone(), venv_dir, version))
     }
 
     #[allow(clippy::too_many_arguments)]
@@ -406,31 +421,92 @@ impl Uv {
     }
 }
 
-// Represents a venv generated and managed by uv
-pub struct UvWithVenv {
+/// Represents uv with any venv
+///
+/// Methods on this type are not allowed to create or modify the underlying virtualenv
+pub struct ReadOnlyVenv {
+    uv: Uv,
+    venv_path: PathBuf,
+}
+
+/// Represents a venv generated and managed by uv
+pub struct ReadWriteVenv {
     uv: Uv,
     venv_path: PathBuf,
     py_version: PythonVersion,
 }
 
-impl UvWithVenv {
-    pub fn new(uv: Uv, venv_dir: &Path, version: &PythonVersion) -> Self {
-        UvWithVenv {
-            uv,
-            py_version: version.clone(),
-            venv_path: venv_dir.to_path_buf(),
-        }
-    }
+pub trait Venv {
+    fn cmd(&self) -> Command;
+
+    fn venv_path(&self) -> &Path;
 
     /// Returns a new command with the uv binary as the command to run.
     /// The command will have the correct proxy settings and verbosity level based on CommandOutput.
     /// The command will also have the VIRTUAL_ENV environment variable set to the venv path.
     fn venv_cmd(&self) -> Command {
-        let mut cmd = self.uv.cmd();
-        cmd.env("VIRTUAL_ENV", &self.venv_path);
+        let mut cmd = self.cmd();
+        cmd.env("VIRTUAL_ENV", self.venv_path());
         cmd
     }
 
+    /// Freezes the venv.
+    fn freeze(&self) -> Result<(), Error> {
+        let status = self
+            .venv_cmd()
+            .arg("pip")
+            .arg("freeze")
+            .status()
+            .with_context(|| format!("unable to freeze venv at {}", self.venv_path().display()))?;
+
+        if !status.success() {
+            return Err(anyhow!(
+                "Failed to freeze venv at {}. uv exited with status: {}",
+                self.venv_path().display(),
+                status
+            ));
+        }
+
+        Ok(())
+    }
+}
+
+impl Venv for ReadOnlyVenv {
+    fn cmd(&self) -> Command {
+        self.uv.cmd()
+    }
+    fn venv_path(&self) -> &Path {
+        &self.venv_path
+    }
+}
+
+impl Venv for ReadWriteVenv {
+    fn cmd(&self) -> Command {
+        self.uv.cmd()
+    }
+    fn venv_path(&self) -> &Path {
+        &self.venv_path
+    }
+}
+
+impl ReadOnlyVenv {
+    pub fn new(uv: Uv, venv_dir: &Path) -> Self {
+        Self {
+            uv,
+            venv_path: venv_dir.to_path_buf(),
+        }
+    }
+}
+
+impl ReadWriteVenv {
+    pub fn new(uv: Uv, venv_dir: &Path, version: &PythonVersion) -> Self {
+        ReadWriteVenv {
+            uv,
+            py_version: version.clone(),
+            venv_path: venv_dir.to_path_buf(),
+        }
+    }
+
     /// Writes a rye-venv.json for this venv.
     pub fn write_marker(&self) -> Result<(), Error> {
         write_venv_marker(&self.venv_path, &self.py_version)
@@ -438,7 +514,7 @@ impl UvWithVenv {
 
     /// Set the output level for subsequent invocations of uv.
     pub fn with_output(self, output: CommandOutput) -> Self {
-        UvWithVenv {
+        Self {
             uv: Uv { output, ..self.uv },
             venv_path: self.venv_path,
             py_version: self.py_version,
@@ -473,26 +549,6 @@ impl UvWithVenv {
         Ok(())
     }
 
-    /// Freezes the venv.
-    pub fn freeze(&self) -> Result<(), Error> {
-        let status = self
-            .venv_cmd()
-            .arg("pip")
-            .arg("freeze")
-            .status()
-            .with_context(|| format!("unable to freeze venv at {}", self.venv_path.display()))?;
-
-        if !status.success() {
-            return Err(anyhow!(
-                "Failed to freeze venv at {}. uv exited with status: {}",
-                self.venv_path.display(),
-                status
-            ));
-        }
-
-        Ok(())
-    }
-
     /// Installs the given requirement in the venv.
     ///
     /// If you provide a list of extras, they will be installed as well.


Test patch:
diff --git a/rye/tests/test-list.rs b/rye/tests/test-list.rs
index 1c7a55fadb..391c1a42ce 100644
--- a/rye/tests/test-list.rs
+++ b/rye/tests/test-list.rs
@@ -56,3 +56,45 @@ fn test_list_not_rye_managed() {
     ----- stderr -----
     "###);
 }
+
+#[test]
+fn test_list_never_overwrite() {
+    let space = Space::new();
+    space.init("my-project");
+
+    space.rye_cmd().arg("sync").status().expect("Sync failed");
+
+    let venv_marker = space.read_string(".venv/rye-venv.json");
+    assert!(
+        venv_marker.contains("@3.12"),
+        "asserting contents of venv marker: {}",
+        venv_marker
+    );
+
+    // Pick different python version
+    space
+        .rye_cmd()
+        .arg("pin")
+        .arg("3.11")
+        .status()
+        .expect("Sync failed");
+
+    // List keeps the existing virtualenv unchanged
+
+    rye_cmd_snapshot!(
+        space.rye_cmd().arg("list"), @r###"
+    success: true
+    exit_code: 0
+    ----- stdout -----
+    -e file:[TEMP_PATH]/project
+
+    ----- stderr -----
+    "###);
+
+    let venv_marker = space.read_string(".venv/rye-venv.json");
+    assert!(
+        venv_marker.contains("@3.12"),
+        "asserting contents of venv marker: {}",
+        venv_marker
+    );
+}