Add basic PyEnv and pixi support for python environments (#37156)

Lukas Wirth created

cc https://github.com/zed-industries/zed/issues/29807

Release Notes:

- Fixed terminals and tasks not respecting python pyenv and pixi
environments

Change summary

crates/language/src/toolchain.rs         |   8 +
crates/languages/src/python.rs           |  71 ++++++++++++--
crates/project/src/debugger/dap_store.rs |   1 
crates/project/src/project_tests.rs      |   6 
crates/project/src/terminals.rs          | 125 ++++++++++++++-----------
crates/remote/src/remote_client.rs       |  12 --
crates/remote/src/transport/ssh.rs       |  12 --
crates/terminal/src/terminal.rs          |  20 +++-
8 files changed, 155 insertions(+), 100 deletions(-)

Detailed changes

crates/language/src/toolchain.rs 🔗

@@ -14,6 +14,7 @@ use collections::HashMap;
 use fs::Fs;
 use gpui::{AsyncApp, SharedString};
 use settings::WorktreeId;
+use task::ShellKind;
 
 use crate::{LanguageName, ManifestName};
 
@@ -68,7 +69,12 @@ pub trait ToolchainLister: Send + Sync {
     fn term(&self) -> SharedString;
     /// Returns the name of the manifest file for this toolchain.
     fn manifest_name(&self) -> ManifestName;
-    async fn activation_script(&self, toolchain: &Toolchain, fs: &dyn Fs) -> Option<String>;
+    async fn activation_script(
+        &self,
+        toolchain: &Toolchain,
+        shell: ShellKind,
+        fs: &dyn Fs,
+    ) -> Vec<String>;
 }
 
 #[async_trait(?Send)]

crates/languages/src/python.rs 🔗

@@ -34,7 +34,7 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
-use task::{TaskTemplate, TaskTemplates, VariableName};
+use task::{ShellKind, TaskTemplate, TaskTemplates, VariableName};
 use util::ResultExt;
 
 pub(crate) struct PyprojectTomlManifestProvider;
@@ -894,20 +894,65 @@ impl ToolchainLister for PythonToolchainProvider {
     fn term(&self) -> SharedString {
         self.term.clone()
     }
-    async fn activation_script(&self, toolchain: &Toolchain, fs: &dyn Fs) -> Option<String> {
-        let toolchain = serde_json::from_value::<pet_core::python_environment::PythonEnvironment>(
+    async fn activation_script(
+        &self,
+        toolchain: &Toolchain,
+        shell: ShellKind,
+        fs: &dyn Fs,
+    ) -> Vec<String> {
+        let Ok(toolchain) = serde_json::from_value::<pet_core::python_environment::PythonEnvironment>(
             toolchain.as_json.clone(),
-        )
-        .ok()?;
-        let mut activation_script = None;
-        if let Some(prefix) = &toolchain.prefix {
-            #[cfg(not(target_os = "windows"))]
-            let path = prefix.join(BINARY_DIR).join("activate");
-            #[cfg(target_os = "windows")]
-            let path = prefix.join(BINARY_DIR).join("activate.ps1");
-            if fs.is_file(&path).await {
-                activation_script = Some(format!(". {}", path.display()));
+        ) else {
+            return vec![];
+        };
+        let mut activation_script = vec![];
+
+        match toolchain.kind {
+            Some(PythonEnvironmentKind::Pixi) => {
+                let env = toolchain.name.as_deref().unwrap_or("default");
+                activation_script.push(format!("pixi shell -e {env}"))
+            }
+            Some(PythonEnvironmentKind::Venv | PythonEnvironmentKind::VirtualEnv) => {
+                if let Some(prefix) = &toolchain.prefix {
+                    let activate_keyword = match shell {
+                        ShellKind::Cmd => ".",
+                        ShellKind::Nushell => "overlay use",
+                        ShellKind::Powershell => ".",
+                        ShellKind::Fish => "source",
+                        ShellKind::Csh => "source",
+                        ShellKind::Posix => "source",
+                    };
+                    let activate_script_name = match shell {
+                        ShellKind::Posix => "activate",
+                        ShellKind::Csh => "activate.csh",
+                        ShellKind::Fish => "activate.fish",
+                        ShellKind::Nushell => "activate.nu",
+                        ShellKind::Powershell => "activate.ps1",
+                        ShellKind::Cmd => "activate.bat",
+                    };
+                    let path = prefix.join(BINARY_DIR).join(activate_script_name);
+                    if fs.is_file(&path).await {
+                        activation_script.push(format!("{activate_keyword} {}", path.display()));
+                    }
+                }
+            }
+            Some(PythonEnvironmentKind::Pyenv) => {
+                let Some(manager) = toolchain.manager else {
+                    return vec![];
+                };
+                let version = toolchain.version.as_deref().unwrap_or("system");
+                let pyenv = manager.executable;
+                let pyenv = pyenv.display();
+                activation_script.extend(match shell {
+                    ShellKind::Fish => Some(format!("{pyenv} shell - fish {version}")),
+                    ShellKind::Posix => Some(format!("{pyenv} shell - sh {version}")),
+                    ShellKind::Nushell => Some(format!("{pyenv} shell - nu {version}")),
+                    ShellKind::Powershell => None,
+                    ShellKind::Csh => None,
+                    ShellKind::Cmd => None,
+                })
             }
+            _ => {}
         }
         activation_script
     }

crates/project/src/project_tests.rs 🔗

@@ -40,7 +40,7 @@ use serde_json::json;
 #[cfg(not(windows))]
 use std::os;
 use std::{env, mem, num::NonZeroU32, ops::Range, str::FromStr, sync::OnceLock, task::Poll};
-use task::{ResolvedTask, TaskContext};
+use task::{ResolvedTask, ShellKind, TaskContext};
 use unindent::Unindent as _;
 use util::{
     TryFutureExt as _, assert_set_eq, maybe, path,
@@ -9222,8 +9222,8 @@ fn python_lang(fs: Arc<FakeFs>) -> Arc<Language> {
         fn manifest_name(&self) -> ManifestName {
             SharedString::new_static("pyproject.toml").into()
         }
-        async fn activation_script(&self, _: &Toolchain, _: &dyn Fs) -> Option<String> {
-            None
+        async fn activation_script(&self, _: &Toolchain, _: ShellKind, _: &dyn Fs) -> Vec<String> {
+            vec![]
         }
     }
     Arc::new(

crates/project/src/terminals.rs 🔗

@@ -1,7 +1,8 @@
 use anyhow::Result;
 use collections::HashMap;
 use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity};
-use itertools::Itertools;
+
+use itertools::Itertools as _;
 use language::LanguageName;
 use remote::RemoteClient;
 use settings::{Settings, SettingsLocation};
@@ -11,7 +12,7 @@ use std::{
     path::{Path, PathBuf},
     sync::Arc,
 };
-use task::{Shell, ShellBuilder, SpawnInTerminal};
+use task::{Shell, ShellBuilder, ShellKind, SpawnInTerminal};
 use terminal::{
     TaskState, TaskStatus, Terminal, TerminalBuilder, terminal_settings::TerminalSettings,
 };
@@ -131,33 +132,62 @@ impl Project {
         cx.spawn(async move |project, cx| {
             let activation_script = maybe!(async {
                 let toolchain = toolchain?.await?;
-                lang_registry
-                    .language_for_name(&toolchain.language_name.0)
-                    .await
-                    .ok()?
-                    .toolchain_lister()?
-                    .activation_script(&toolchain, fs.as_ref())
-                    .await
+                Some(
+                    lang_registry
+                        .language_for_name(&toolchain.language_name.0)
+                        .await
+                        .ok()?
+                        .toolchain_lister()?
+                        .activation_script(&toolchain, ShellKind::new(&shell), fs.as_ref())
+                        .await,
+                )
             })
-            .await;
+            .await
+            .unwrap_or_default();
 
             project.update(cx, move |this, cx| {
                 let shell = {
                     env.extend(spawn_task.env);
                     match remote_client {
-                        Some(remote_client) => create_remote_shell(
-                            spawn_task
-                                .command
-                                .as_ref()
-                                .map(|command| (command, &spawn_task.args)),
-                            &mut env,
-                            path,
-                            remote_client,
-                            activation_script.clone(),
-                            cx,
-                        )?,
+                        Some(remote_client) => match activation_script.clone() {
+                            activation_script if !activation_script.is_empty() => {
+                                let activation_script = activation_script.join("; ");
+                                let to_run = if let Some(command) = spawn_task.command {
+                                    let command: Option<Cow<str>> = shlex::try_quote(&command).ok();
+                                    let args = spawn_task
+                                        .args
+                                        .iter()
+                                        .filter_map(|arg| shlex::try_quote(arg).ok());
+                                    command.into_iter().chain(args).join(" ")
+                                } else {
+                                    format!("exec {shell} -l")
+                                };
+                                let args = vec![
+                                    "-c".to_owned(),
+                                    format!("{activation_script}; {to_run}",),
+                                ];
+                                create_remote_shell(
+                                    Some((&shell, &args)),
+                                    &mut env,
+                                    path,
+                                    remote_client,
+                                    cx,
+                                )?
+                            }
+                            _ => create_remote_shell(
+                                spawn_task
+                                    .command
+                                    .as_ref()
+                                    .map(|command| (command, &spawn_task.args)),
+                                &mut env,
+                                path,
+                                remote_client,
+                                cx,
+                            )?,
+                        },
                         None => match activation_script.clone() {
-                            Some(activation_script) => {
+                            activation_script if !activation_script.is_empty() => {
+                                let activation_script = activation_script.join("; ");
                                 let to_run = if let Some(command) = spawn_task.command {
                                     let command: Option<Cow<str>> = shlex::try_quote(&command).ok();
                                     let args = spawn_task
@@ -169,7 +199,7 @@ impl Project {
                                     format!("exec {shell} -l")
                                 };
                                 Shell::WithArguments {
-                                    program: get_default_system_shell(),
+                                    program: shell,
                                     args: vec![
                                         "-c".to_owned(),
                                         format!("{activation_script}; {to_run}",),
@@ -177,7 +207,7 @@ impl Project {
                                     title_override: None,
                                 }
                             }
-                            None => {
+                            _ => {
                                 if let Some(program) = spawn_task.command {
                                     Shell::WithArguments {
                                         program,
@@ -302,31 +332,21 @@ impl Project {
                     .await
                     .ok();
                 let lister = language?.toolchain_lister();
-                lister?.activation_script(&toolchain, fs.as_ref()).await
+                Some(
+                    lister?
+                        .activation_script(&toolchain, ShellKind::new(&shell), fs.as_ref())
+                        .await,
+                )
             })
-            .await;
+            .await
+            .unwrap_or_default();
             project.update(cx, move |this, cx| {
                 let shell = {
                     match remote_client {
-                        Some(remote_client) => create_remote_shell(
-                            None,
-                            &mut env,
-                            path,
-                            remote_client,
-                            activation_script.clone(),
-                            cx,
-                        )?,
-                        None => match activation_script.clone() {
-                            Some(activation_script) => Shell::WithArguments {
-                                program: get_default_system_shell(),
-                                args: vec![
-                                    "-c".to_owned(),
-                                    format!("{activation_script}; exec {shell} -l",),
-                                ],
-                                title_override: Some(shell.into()),
-                            },
-                            None => settings.shell,
-                        },
+                        Some(remote_client) => {
+                            create_remote_shell(None, &mut env, path, remote_client, cx)?
+                        }
+                        None => settings.shell,
                     }
                 };
                 TerminalBuilder::new(
@@ -437,15 +457,10 @@ impl Project {
 
         match remote_client {
             Some(remote_client) => {
-                let command_template = remote_client.read(cx).build_command(
-                    Some(command),
-                    &args,
-                    &env,
-                    None,
-                    // todo
-                    None,
-                    None,
-                )?;
+                let command_template =
+                    remote_client
+                        .read(cx)
+                        .build_command(Some(command), &args, &env, None, None)?;
                 let mut command = std::process::Command::new(command_template.program);
                 command.args(command_template.args);
                 command.envs(command_template.env);
@@ -473,7 +488,6 @@ fn create_remote_shell(
     env: &mut HashMap<String, String>,
     working_directory: Option<Arc<Path>>,
     remote_client: Entity<RemoteClient>,
-    activation_script: Option<String>,
     cx: &mut App,
 ) -> Result<Shell> {
     // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
@@ -493,7 +507,6 @@ fn create_remote_shell(
         args.as_slice(),
         env,
         working_directory.map(|path| path.display().to_string()),
-        activation_script,
         None,
     )?;
     *env = command.env;

crates/remote/src/remote_client.rs 🔗

@@ -757,7 +757,6 @@ impl RemoteClient {
         args: &[String],
         env: &HashMap<String, String>,
         working_dir: Option<String>,
-        activation_script: Option<String>,
         port_forward: Option<(u16, String, u16)>,
     ) -> Result<CommandTemplate> {
         let Some(connection) = self
@@ -767,14 +766,7 @@ impl RemoteClient {
         else {
             return Err(anyhow!("no connection"));
         };
-        connection.build_command(
-            program,
-            args,
-            env,
-            working_dir,
-            activation_script,
-            port_forward,
-        )
+        connection.build_command(program, args, env, working_dir, port_forward)
     }
 
     pub fn upload_directory(
@@ -1006,7 +998,6 @@ pub(crate) trait RemoteConnection: Send + Sync {
         args: &[String],
         env: &HashMap<String, String>,
         working_dir: Option<String>,
-        activation_script: Option<String>,
         port_forward: Option<(u16, String, u16)>,
     ) -> Result<CommandTemplate>;
     fn connection_options(&self) -> SshConnectionOptions;
@@ -1373,7 +1364,6 @@ mod fake {
             args: &[String],
             env: &HashMap<String, String>,
             _: Option<String>,
-            _: Option<String>,
             _: Option<(u16, String, u16)>,
         ) -> Result<CommandTemplate> {
             let ssh_program = program.unwrap_or_else(|| "sh".to_string());

crates/remote/src/transport/ssh.rs 🔗

@@ -30,10 +30,7 @@ use std::{
     time::Instant,
 };
 use tempfile::TempDir;
-use util::{
-    get_default_system_shell,
-    paths::{PathStyle, RemotePathBuf},
-};
+use util::paths::{PathStyle, RemotePathBuf};
 
 pub(crate) struct SshRemoteConnection {
     socket: SshSocket,
@@ -116,7 +113,6 @@ impl RemoteConnection for SshRemoteConnection {
         input_args: &[String],
         input_env: &HashMap<String, String>,
         working_dir: Option<String>,
-        activation_script: Option<String>,
         port_forward: Option<(u16, String, u16)>,
     ) -> Result<CommandTemplate> {
         use std::fmt::Write as _;
@@ -138,9 +134,6 @@ impl RemoteConnection for SshRemoteConnection {
         } else {
             write!(&mut script, "cd; ").unwrap();
         };
-        if let Some(activation_script) = activation_script {
-            write!(&mut script, " {activation_script};").unwrap();
-        }
 
         for (k, v) in input_env.iter() {
             if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) {
@@ -162,8 +155,7 @@ impl RemoteConnection for SshRemoteConnection {
             write!(&mut script, "exec {shell} -l").unwrap();
         };
 
-        let sys_shell = get_default_system_shell();
-        let shell_invocation = format!("{sys_shell} -c {}", shlex::try_quote(&script).unwrap());
+        let shell_invocation = format!("{shell} -c {}", shlex::try_quote(&script).unwrap());
 
         let mut args = Vec::new();
         args.extend(self.socket.ssh_args());

crates/terminal/src/terminal.rs 🔗

@@ -354,7 +354,7 @@ impl TerminalBuilder {
         window_id: u64,
         completion_tx: Option<Sender<Option<ExitStatus>>>,
         cx: &App,
-        activation_script: Option<String>,
+        activation_script: Vec<String>,
     ) -> Result<TerminalBuilder> {
         // If the parent environment doesn't have a locale set
         // (As is the case when launched from a .app on MacOS),
@@ -493,7 +493,9 @@ impl TerminalBuilder {
         let pty_tx = event_loop.channel();
         let _io_thread = event_loop.spawn(); // DANGER
 
-        let terminal = Terminal {
+        let no_task = task.is_none();
+
+        let mut terminal = Terminal {
             task,
             pty_tx: Notifier(pty_tx),
             completion_tx,
@@ -518,7 +520,7 @@ impl TerminalBuilder {
             last_hyperlink_search_position: None,
             #[cfg(windows)]
             shell_program,
-            activation_script,
+            activation_script: activation_script.clone(),
             template: CopyTemplate {
                 shell,
                 env,
@@ -529,6 +531,14 @@ impl TerminalBuilder {
             },
         };
 
+        if !activation_script.is_empty() && no_task {
+            for activation_script in activation_script {
+                terminal.input(activation_script.into_bytes());
+                terminal.write_to_pty(b"\n");
+            }
+            terminal.clear();
+        }
+
         Ok(TerminalBuilder {
             terminal,
             events_rx,
@@ -712,7 +722,7 @@ pub struct Terminal {
     #[cfg(windows)]
     shell_program: Option<String>,
     template: CopyTemplate,
-    activation_script: Option<String>,
+    activation_script: Vec<String>,
 }
 
 struct CopyTemplate {
@@ -2218,7 +2228,7 @@ mod tests {
                 0,
                 Some(completion_tx),
                 cx,
-                None,
+                vec![],
             )
             .unwrap()
             .subscribe(cx)