python.rs

  1use crate::*;
  2use anyhow::Context as _;
  3use dap::adapters::latest_github_release;
  4use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
  5use gpui::{AppContext, AsyncApp, SharedString};
  6use json_dotpath::DotPaths;
  7use language::{LanguageName, Toolchain};
  8use serde_json::Value;
  9use std::borrow::Cow;
 10use std::net::Ipv4Addr;
 11use std::sync::LazyLock;
 12use std::{
 13    collections::HashMap,
 14    ffi::OsStr,
 15    path::{Path, PathBuf},
 16    sync::OnceLock,
 17};
 18#[cfg(feature = "update-schemas")]
 19use tempfile::TempDir;
 20use util::ResultExt;
 21
 22#[derive(Default)]
 23pub struct PythonDebugAdapter {
 24    checked: OnceLock<()>,
 25}
 26
 27impl PythonDebugAdapter {
 28    pub const ADAPTER_NAME: &'static str = "Debugpy";
 29    const DEBUG_ADAPTER_NAME: DebugAdapterName =
 30        DebugAdapterName(SharedString::new_static(Self::ADAPTER_NAME));
 31    const ADAPTER_PACKAGE_NAME: &'static str = "debugpy";
 32    const ADAPTER_PATH: &'static str = "src/debugpy/adapter";
 33    const LANGUAGE_NAME: &'static str = "Python";
 34
 35    async fn generate_debugpy_arguments(
 36        host: &Ipv4Addr,
 37        port: u16,
 38        user_installed_path: Option<&Path>,
 39        user_args: Option<Vec<String>>,
 40        installed_in_venv: bool,
 41    ) -> Result<Vec<String>> {
 42        let mut args = if let Some(user_installed_path) = user_installed_path {
 43            log::debug!(
 44                "Using user-installed debugpy adapter from: {}",
 45                user_installed_path.display()
 46            );
 47            vec![
 48                user_installed_path
 49                    .join(Self::ADAPTER_PATH)
 50                    .to_string_lossy()
 51                    .to_string(),
 52            ]
 53        } else if installed_in_venv {
 54            log::debug!("Using venv-installed debugpy");
 55            vec!["-m".to_string(), "debugpy.adapter".to_string()]
 56        } else {
 57            let adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
 58            let file_name_prefix = format!("{}_", Self::ADAPTER_NAME);
 59
 60            let debugpy_dir =
 61                util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
 62                    file_name.starts_with(&file_name_prefix)
 63                })
 64                .await
 65                .context("Debugpy directory not found")?;
 66
 67            log::debug!(
 68                "Using GitHub-downloaded debugpy adapter from: {}",
 69                debugpy_dir.display()
 70            );
 71            vec![
 72                debugpy_dir
 73                    .join(Self::ADAPTER_PATH)
 74                    .to_string_lossy()
 75                    .to_string(),
 76            ]
 77        };
 78
 79        args.extend(if let Some(args) = user_args {
 80            args
 81        } else {
 82            vec![format!("--host={}", host), format!("--port={}", port)]
 83        });
 84        Ok(args)
 85    }
 86
 87    async fn request_args(
 88        &self,
 89        delegate: &Arc<dyn DapDelegate>,
 90        task_definition: &DebugTaskDefinition,
 91    ) -> Result<StartDebuggingRequestArguments> {
 92        let request = self.request_kind(&task_definition.config).await?;
 93
 94        let mut configuration = task_definition.config.clone();
 95        if let Ok(console) = configuration.dot_get_mut("console") {
 96            // Use built-in Zed terminal if user did not explicitly provide a setting for console.
 97            if console.is_null() {
 98                *console = Value::String("integratedTerminal".into());
 99            }
100        }
101
102        if let Some(obj) = configuration.as_object_mut() {
103            obj.entry("cwd")
104                .or_insert(delegate.worktree_root_path().to_string_lossy().into());
105        }
106
107        Ok(StartDebuggingRequestArguments {
108            configuration,
109            request,
110        })
111    }
112
113    async fn fetch_latest_adapter_version(
114        &self,
115        delegate: &Arc<dyn DapDelegate>,
116    ) -> Result<AdapterVersion> {
117        let github_repo = GithubRepo {
118            repo_name: Self::ADAPTER_PACKAGE_NAME.into(),
119            repo_owner: "microsoft".into(),
120        };
121
122        fetch_latest_adapter_version_from_github(github_repo, delegate.as_ref()).await
123    }
124
125    async fn install_binary(
126        adapter_name: DebugAdapterName,
127        version: AdapterVersion,
128        delegate: Arc<dyn DapDelegate>,
129    ) -> Result<()> {
130        let version_path = adapters::download_adapter_from_github(
131            adapter_name.as_ref(),
132            version,
133            adapters::DownloadedFileType::GzipTar,
134            paths::debug_adapters_dir(),
135            delegate.as_ref(),
136        )
137        .await?;
138        // only needed when you install the latest version for the first time
139        if let Some(debugpy_dir) =
140            util::fs::find_file_name_in_dir(version_path.as_path(), |file_name| {
141                file_name.starts_with("microsoft-debugpy-")
142            })
143            .await
144        {
145            // TODO Debugger: Rename folder instead of moving all files to another folder
146            // We're doing unnecessary IO work right now
147            util::fs::move_folder_files_to_folder(debugpy_dir.as_path(), version_path.as_path())
148                .await?;
149        }
150
151        Ok(())
152    }
153
154    async fn get_installed_binary(
155        &self,
156        delegate: &Arc<dyn DapDelegate>,
157        config: &DebugTaskDefinition,
158        user_installed_path: Option<PathBuf>,
159        user_args: Option<Vec<String>>,
160        toolchain: Option<Toolchain>,
161        installed_in_venv: bool,
162    ) -> Result<DebugAdapterBinary> {
163        const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
164        let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
165        let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
166
167        let python_path = if let Some(toolchain) = toolchain {
168            Some(toolchain.path.to_string())
169        } else {
170            let mut name = None;
171
172            for cmd in BINARY_NAMES {
173                name = delegate
174                    .which(OsStr::new(cmd))
175                    .await
176                    .map(|path| path.to_string_lossy().to_string());
177                if name.is_some() {
178                    break;
179                }
180            }
181            name
182        };
183
184        let python_command = python_path.context("failed to find binary path for Python")?;
185        log::debug!("Using Python executable: {}", python_command);
186
187        let arguments = Self::generate_debugpy_arguments(
188            &host,
189            port,
190            user_installed_path.as_deref(),
191            user_args,
192            installed_in_venv,
193        )
194        .await?;
195
196        log::debug!(
197            "Starting debugpy adapter with command: {} {}",
198            python_command,
199            arguments.join(" ")
200        );
201
202        Ok(DebugAdapterBinary {
203            command: Some(python_command),
204            arguments,
205            connection: Some(adapters::TcpArguments {
206                host,
207                port,
208                timeout,
209            }),
210            cwd: Some(delegate.worktree_root_path().to_path_buf()),
211            envs: HashMap::default(),
212            request_args: self.request_args(delegate, config).await?,
213        })
214    }
215}
216
217#[async_trait(?Send)]
218impl DebugAdapter for PythonDebugAdapter {
219    fn name(&self) -> DebugAdapterName {
220        Self::DEBUG_ADAPTER_NAME
221    }
222
223    fn adapter_language_name(&self) -> Option<LanguageName> {
224        Some(SharedString::new_static("Python").into())
225    }
226
227    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
228        let mut args = json!({
229            "request": match zed_scenario.request {
230                DebugRequest::Launch(_) => "launch",
231                DebugRequest::Attach(_) => "attach",
232            },
233            "subProcess": true,
234            "redirectOutput": true,
235        });
236
237        let map = args.as_object_mut().unwrap();
238        match &zed_scenario.request {
239            DebugRequest::Attach(attach) => {
240                map.insert("processId".into(), attach.process_id.into());
241            }
242            DebugRequest::Launch(launch) => {
243                map.insert("program".into(), launch.program.clone().into());
244                map.insert("args".into(), launch.args.clone().into());
245                if !launch.env.is_empty() {
246                    map.insert("env".into(), launch.env_json());
247                }
248
249                if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
250                    map.insert("stopOnEntry".into(), stop_on_entry.into());
251                }
252                if let Some(cwd) = launch.cwd.as_ref() {
253                    map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
254                }
255            }
256        }
257
258        Ok(DebugScenario {
259            adapter: zed_scenario.adapter,
260            label: zed_scenario.label,
261            config: args,
262            build: None,
263            tcp_connection: None,
264        })
265    }
266
267    fn dap_schema(&self) -> Cow<'static, serde_json::Value> {
268        static SCHEMA: LazyLock<serde_json::Value> = LazyLock::new(|| {
269            const RAW_SCHEMA: &str = include_str!("../schemas/Debugpy.json");
270            serde_json::from_str(RAW_SCHEMA).unwrap()
271        });
272        Cow::Borrowed(&*SCHEMA)
273    }
274
275    async fn get_binary(
276        &self,
277        delegate: &Arc<dyn DapDelegate>,
278        config: &DebugTaskDefinition,
279        user_installed_path: Option<PathBuf>,
280        user_args: Option<Vec<String>>,
281        cx: &mut AsyncApp,
282    ) -> Result<DebugAdapterBinary> {
283        if let Some(local_path) = &user_installed_path {
284            log::debug!(
285                "Using user-installed debugpy adapter from: {}",
286                local_path.display()
287            );
288            return self
289                .get_installed_binary(
290                    delegate,
291                    &config,
292                    Some(local_path.clone()),
293                    user_args,
294                    None,
295                    false,
296                )
297                .await;
298        }
299
300        let toolchain = delegate
301            .toolchain_store()
302            .active_toolchain(
303                delegate.worktree_id(),
304                Arc::from("".as_ref()),
305                language::LanguageName::new(Self::LANGUAGE_NAME),
306                cx,
307            )
308            .await;
309
310        if let Some(toolchain) = &toolchain {
311            if let Some(path) = Path::new(&toolchain.path.to_string()).parent() {
312                let debugpy_path = path.join("debugpy");
313                if delegate.fs().is_file(&debugpy_path).await {
314                    log::debug!(
315                        "Found debugpy in toolchain environment: {}",
316                        debugpy_path.display()
317                    );
318                    return self
319                        .get_installed_binary(
320                            delegate,
321                            &config,
322                            None,
323                            user_args,
324                            Some(toolchain.clone()),
325                            true,
326                        )
327                        .await;
328                }
329            }
330        }
331
332        if self.checked.set(()).is_ok() {
333            delegate.output_to_console(format!("Checking latest version of {}...", self.name()));
334            if let Some(version) = self.fetch_latest_adapter_version(delegate).await.log_err() {
335                cx.background_spawn(Self::install_binary(self.name(), version, delegate.clone()))
336                    .await
337                    .context("Failed to install debugpy")?;
338            }
339        }
340
341        self.get_installed_binary(delegate, &config, None, user_args, toolchain, false)
342            .await
343    }
344
345    fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option<String> {
346        let label = args
347            .configuration
348            .get("name")?
349            .as_str()
350            .filter(|label| !label.is_empty())?;
351        Some(label.to_owned())
352    }
353}
354
355#[cfg(feature = "update-schemas")]
356impl PythonDebugAdapter {
357    pub fn get_schema(
358        temp_dir: &TempDir,
359        delegate: UpdateSchemasDapDelegate,
360    ) -> anyhow::Result<serde_json::Value> {
361        let temp_dir = std::fs::canonicalize(temp_dir.path())?;
362        let fs = delegate.fs.clone();
363        let executor = delegate.executor.clone();
364
365        let (package_json, package_nls_json) = executor.block(async move {
366            let version = fetch_latest_adapter_version_from_github(
367                GithubRepo {
368                    repo_name: "vscode-python-debugger".into(),
369                    repo_owner: "microsoft".into(),
370                },
371                &delegate,
372            )
373            .await?;
374
375            let path = adapters::download_adapter_from_github(
376                "schemas",
377                version,
378                adapters::DownloadedFileType::GzipTar,
379                &temp_dir,
380                &delegate,
381            )
382            .await?;
383
384            let path = util::fs::find_file_name_in_dir(path.as_path(), |file_name| {
385                file_name.starts_with("microsoft-vscode-python-debugger-")
386            })
387            .await
388            .context("find python debugger extension in download")?;
389
390            let package_json = fs.load(&path.join("package.json")).await?;
391            let package_nls_json = fs.load(&path.join("package.nls.json")).await.ok();
392
393            anyhow::Ok((package_json, package_nls_json))
394        })?;
395
396        let package_json = parse_package_json(package_json, package_nls_json)?;
397
398        let [debugger] =
399            <[_; 1]>::try_from(package_json.contributes.debuggers).map_err(|debuggers| {
400                anyhow::anyhow!("unexpected number of python debuggers: {}", debuggers.len())
401            })?;
402
403        Ok(schema_for_configuration_attributes(
404            debugger.configuration_attributes,
405        ))
406    }
407}
408
409async fn fetch_latest_adapter_version_from_github(
410    github_repo: GithubRepo,
411    delegate: &dyn DapDelegate,
412) -> Result<AdapterVersion> {
413    let release = latest_github_release(
414        &format!("{}/{}", github_repo.repo_owner, github_repo.repo_name),
415        false,
416        false,
417        delegate.http_client(),
418    )
419    .await?;
420
421    Ok(AdapterVersion {
422        tag_name: release.tag_name,
423        url: release.tarball_url,
424    })
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use std::{net::Ipv4Addr, path::PathBuf};
431
432    #[gpui::test]
433    async fn test_debugpy_install_path_cases() {
434        let host = Ipv4Addr::new(127, 0, 0, 1);
435        let port = 5678;
436
437        // Case 1: User-defined debugpy path (highest precedence)
438        let user_path = PathBuf::from("/custom/path/to/debugpy");
439        let user_args = PythonDebugAdapter::generate_debugpy_arguments(
440            &host,
441            port,
442            Some(&user_path),
443            None,
444            false,
445        )
446        .await
447        .unwrap();
448
449        // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
450        let venv_args =
451            PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, None, true)
452                .await
453                .unwrap();
454
455        assert!(user_args[0].ends_with("src/debugpy/adapter"));
456        assert_eq!(user_args[1], "--host=127.0.0.1");
457        assert_eq!(user_args[2], "--port=5678");
458
459        assert_eq!(venv_args[0], "-m");
460        assert_eq!(venv_args[1], "debugpy.adapter");
461        assert_eq!(venv_args[2], "--host=127.0.0.1");
462        assert_eq!(venv_args[3], "--port=5678");
463
464        // The same cases, with arguments overridden by the user
465        let user_args = PythonDebugAdapter::generate_debugpy_arguments(
466            &host,
467            port,
468            Some(&user_path),
469            Some(vec!["foo".into()]),
470            false,
471        )
472        .await
473        .unwrap();
474        let venv_args = PythonDebugAdapter::generate_debugpy_arguments(
475            &host,
476            port,
477            None,
478            Some(vec!["foo".into()]),
479            true,
480        )
481        .await
482        .unwrap();
483
484        assert!(user_args[0].ends_with("src/debugpy/adapter"));
485        assert_eq!(user_args[1], "foo");
486
487        assert_eq!(venv_args[0], "-m");
488        assert_eq!(venv_args[1], "debugpy.adapter");
489        assert_eq!(venv_args[2], "foo");
490
491        // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
492    }
493}