1use crate::*;
2use anyhow::Context as _;
3use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
4use fs::RemoveOptions;
5use futures::{StreamExt, TryStreamExt};
6use gpui::http_client::AsyncBody;
7use gpui::{AsyncApp, SharedString};
8use json_dotpath::DotPaths;
9use language::LanguageName;
10use paths::debug_adapters_dir;
11use serde_json::Value;
12use smol::fs::File;
13use smol::io::AsyncReadExt;
14use smol::lock::OnceCell;
15use std::ffi::OsString;
16use std::net::Ipv4Addr;
17use std::str::FromStr;
18use std::{
19 collections::HashMap,
20 ffi::OsStr,
21 path::{Path, PathBuf},
22};
23use util::{ResultExt, maybe};
24
25#[derive(Default)]
26pub(crate) struct PythonDebugAdapter {
27 base_venv_path: OnceCell<Result<Arc<Path>, String>>,
28 debugpy_whl_base_path: OnceCell<Result<Arc<Path>, String>>,
29}
30
31impl PythonDebugAdapter {
32 const ADAPTER_NAME: &'static str = "Debugpy";
33 const DEBUG_ADAPTER_NAME: DebugAdapterName =
34 DebugAdapterName(SharedString::new_static(Self::ADAPTER_NAME));
35
36 const LANGUAGE_NAME: &'static str = "Python";
37
38 async fn generate_debugpy_arguments(
39 host: &Ipv4Addr,
40 port: u16,
41 user_installed_path: Option<&Path>,
42 user_args: Option<Vec<String>>,
43 ) -> Result<Vec<String>> {
44 let mut args = if let Some(user_installed_path) = user_installed_path {
45 log::debug!(
46 "Using user-installed debugpy adapter from: {}",
47 user_installed_path.display()
48 );
49 vec![user_installed_path.to_string_lossy().to_string()]
50 } else {
51 let adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
52 let path = adapter_path
53 .join("debugpy")
54 .join("adapter")
55 .to_string_lossy()
56 .into_owned();
57 log::debug!("Using pip debugpy adapter from: {path}");
58 vec![path]
59 };
60
61 args.extend(if let Some(args) = user_args {
62 args
63 } else {
64 vec![format!("--host={}", host), format!("--port={}", port)]
65 });
66 Ok(args)
67 }
68
69 async fn request_args(
70 &self,
71 delegate: &Arc<dyn DapDelegate>,
72 task_definition: &DebugTaskDefinition,
73 ) -> Result<StartDebuggingRequestArguments> {
74 let request = self.request_kind(&task_definition.config).await?;
75
76 let mut configuration = task_definition.config.clone();
77 if let Ok(console) = configuration.dot_get_mut("console") {
78 // Use built-in Zed terminal if user did not explicitly provide a setting for console.
79 if console.is_null() {
80 *console = Value::String("integratedTerminal".into());
81 }
82 }
83
84 if let Some(obj) = configuration.as_object_mut() {
85 obj.entry("cwd")
86 .or_insert(delegate.worktree_root_path().to_string_lossy().into());
87 }
88
89 Ok(StartDebuggingRequestArguments {
90 configuration,
91 request,
92 })
93 }
94
95 async fn fetch_wheel(&self, delegate: &Arc<dyn DapDelegate>) -> Result<Arc<Path>, String> {
96 let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME).join("wheels");
97 std::fs::create_dir_all(&download_dir).map_err(|e| e.to_string())?;
98 let system_python = self.base_venv_path(delegate).await?;
99
100 let installation_succeeded = util::command::new_smol_command(system_python.as_ref())
101 .args([
102 "-m",
103 "pip",
104 "download",
105 "debugpy",
106 "--only-binary=:all:",
107 "-d",
108 download_dir.to_string_lossy().as_ref(),
109 ])
110 .output()
111 .await
112 .map_err(|e| format!("{e}"))?
113 .status
114 .success();
115 if !installation_succeeded {
116 return Err("debugpy installation failed (could not fetch Debugpy's wheel)".into());
117 }
118
119 let wheel_path = std::fs::read_dir(&download_dir)
120 .map_err(|e| e.to_string())?
121 .find_map(|entry| {
122 entry.ok().filter(|e| {
123 e.file_type().is_ok_and(|typ| typ.is_file())
124 && Path::new(&e.file_name()).extension() == Some("whl".as_ref())
125 })
126 })
127 .ok_or_else(|| String::from("Did not find a .whl in {download_dir}"))?;
128
129 util::archive::extract_zip(
130 &debug_adapters_dir().join(Self::ADAPTER_NAME),
131 File::open(&wheel_path.path())
132 .await
133 .map_err(|e| e.to_string())?,
134 )
135 .await
136 .map_err(|e| e.to_string())?;
137
138 Ok(Arc::from(wheel_path.path()))
139 }
140
141 async fn maybe_fetch_new_wheel(&self, delegate: &Arc<dyn DapDelegate>) {
142 let latest_release = delegate
143 .http_client()
144 .get(
145 "https://pypi.org/pypi/debugpy/json",
146 AsyncBody::empty(),
147 false,
148 )
149 .await
150 .log_err();
151 maybe!(async move {
152 let response = latest_release.filter(|response| response.status().is_success())?;
153
154 let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME);
155 std::fs::create_dir_all(&download_dir).ok()?;
156
157 let mut output = String::new();
158 response
159 .into_body()
160 .read_to_string(&mut output)
161 .await
162 .ok()?;
163 let as_json = serde_json::Value::from_str(&output).ok()?;
164 let latest_version = as_json.get("info").and_then(|info| {
165 info.get("version")
166 .and_then(|version| version.as_str())
167 .map(ToOwned::to_owned)
168 })?;
169 let dist_info_dirname: OsString = format!("debugpy-{latest_version}.dist-info").into();
170 let is_up_to_date = delegate
171 .fs()
172 .read_dir(&debug_adapters_dir().join(Self::ADAPTER_NAME))
173 .await
174 .ok()?
175 .into_stream()
176 .any(async |entry| {
177 entry.is_ok_and(|e| e.file_name().is_some_and(|name| name == dist_info_dirname))
178 })
179 .await;
180
181 if !is_up_to_date {
182 delegate
183 .fs()
184 .remove_dir(
185 &debug_adapters_dir().join(Self::ADAPTER_NAME),
186 RemoveOptions {
187 recursive: true,
188 ignore_if_not_exists: true,
189 },
190 )
191 .await
192 .ok()?;
193 self.fetch_wheel(delegate).await.ok()?;
194 }
195 Some(())
196 })
197 .await;
198 }
199
200 async fn fetch_debugpy_whl(
201 &self,
202 delegate: &Arc<dyn DapDelegate>,
203 ) -> Result<Arc<Path>, String> {
204 self.debugpy_whl_base_path
205 .get_or_init(|| async move {
206 self.maybe_fetch_new_wheel(delegate).await;
207 Ok(Arc::from(
208 debug_adapters_dir()
209 .join(Self::ADAPTER_NAME)
210 .join("debugpy")
211 .join("adapter")
212 .as_ref(),
213 ))
214 })
215 .await
216 .clone()
217 }
218
219 async fn base_venv_path(&self, delegate: &Arc<dyn DapDelegate>) -> Result<Arc<Path>, String> {
220 self.base_venv_path
221 .get_or_init(|| async {
222 let base_python = Self::system_python_name(delegate)
223 .await
224 .ok_or_else(|| String::from("Could not find a Python installation"))?;
225
226 let did_succeed = util::command::new_smol_command(base_python)
227 .args(["-m", "venv", "zed_base_venv"])
228 .current_dir(
229 paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref()),
230 )
231 .spawn()
232 .map_err(|e| format!("{e:#?}"))?
233 .status()
234 .await
235 .map_err(|e| format!("{e:#?}"))?
236 .success();
237
238 if !did_succeed {
239 return Err("Failed to create base virtual environment".into());
240 }
241
242 const DIR: &str = if cfg!(target_os = "windows") {
243 "Scripts"
244 } else {
245 "bin"
246 };
247 Ok(Arc::from(
248 paths::debug_adapters_dir()
249 .join(Self::DEBUG_ADAPTER_NAME.as_ref())
250 .join("zed_base_venv")
251 .join(DIR)
252 .join("python3")
253 .as_ref(),
254 ))
255 })
256 .await
257 .clone()
258 }
259 async fn system_python_name(delegate: &Arc<dyn DapDelegate>) -> Option<String> {
260 const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
261 let mut name = None;
262
263 for cmd in BINARY_NAMES {
264 name = delegate
265 .which(OsStr::new(cmd))
266 .await
267 .map(|path| path.to_string_lossy().to_string());
268 if name.is_some() {
269 break;
270 }
271 }
272 name
273 }
274
275 async fn get_installed_binary(
276 &self,
277 delegate: &Arc<dyn DapDelegate>,
278 config: &DebugTaskDefinition,
279 user_installed_path: Option<PathBuf>,
280 user_args: Option<Vec<String>>,
281 python_from_toolchain: Option<String>,
282 ) -> Result<DebugAdapterBinary> {
283 let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
284 let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
285
286 let python_path = if let Some(toolchain) = python_from_toolchain {
287 Some(toolchain)
288 } else {
289 Self::system_python_name(delegate).await
290 };
291
292 let python_command = python_path.context("failed to find binary path for Python")?;
293 log::debug!("Using Python executable: {}", python_command);
294
295 let arguments = Self::generate_debugpy_arguments(
296 &host,
297 port,
298 user_installed_path.as_deref(),
299 user_args,
300 )
301 .await?;
302
303 log::debug!(
304 "Starting debugpy adapter with command: {} {}",
305 python_command,
306 arguments.join(" ")
307 );
308
309 Ok(DebugAdapterBinary {
310 command: Some(python_command),
311 arguments,
312 connection: Some(adapters::TcpArguments {
313 host,
314 port,
315 timeout,
316 }),
317 cwd: Some(delegate.worktree_root_path().to_path_buf()),
318 envs: HashMap::default(),
319 request_args: self.request_args(delegate, config).await?,
320 })
321 }
322}
323
324#[async_trait(?Send)]
325impl DebugAdapter for PythonDebugAdapter {
326 fn name(&self) -> DebugAdapterName {
327 Self::DEBUG_ADAPTER_NAME
328 }
329
330 fn adapter_language_name(&self) -> Option<LanguageName> {
331 Some(SharedString::new_static("Python").into())
332 }
333
334 async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
335 let mut args = json!({
336 "request": match zed_scenario.request {
337 DebugRequest::Launch(_) => "launch",
338 DebugRequest::Attach(_) => "attach",
339 },
340 "subProcess": true,
341 "redirectOutput": true,
342 });
343
344 let map = args.as_object_mut().unwrap();
345 match &zed_scenario.request {
346 DebugRequest::Attach(attach) => {
347 map.insert("processId".into(), attach.process_id.into());
348 }
349 DebugRequest::Launch(launch) => {
350 map.insert("program".into(), launch.program.clone().into());
351 map.insert("args".into(), launch.args.clone().into());
352 if !launch.env.is_empty() {
353 map.insert("env".into(), launch.env_json());
354 }
355
356 if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
357 map.insert("stopOnEntry".into(), stop_on_entry.into());
358 }
359 if let Some(cwd) = launch.cwd.as_ref() {
360 map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
361 }
362 }
363 }
364
365 Ok(DebugScenario {
366 adapter: zed_scenario.adapter,
367 label: zed_scenario.label,
368 config: args,
369 build: None,
370 tcp_connection: None,
371 })
372 }
373
374 fn dap_schema(&self) -> serde_json::Value {
375 json!({
376 "properties": {
377 "request": {
378 "type": "string",
379 "enum": ["attach", "launch"],
380 "description": "Debug adapter request type"
381 },
382 "autoReload": {
383 "default": {},
384 "description": "Configures automatic reload of code on edit.",
385 "properties": {
386 "enable": {
387 "default": false,
388 "description": "Automatically reload code on edit.",
389 "type": "boolean"
390 },
391 "exclude": {
392 "default": [
393 "**/.git/**",
394 "**/.metadata/**",
395 "**/__pycache__/**",
396 "**/node_modules/**",
397 "**/site-packages/**"
398 ],
399 "description": "Glob patterns of paths to exclude from auto reload.",
400 "items": {
401 "type": "string"
402 },
403 "type": "array"
404 },
405 "include": {
406 "default": [
407 "**/*.py",
408 "**/*.pyw"
409 ],
410 "description": "Glob patterns of paths to include in auto reload.",
411 "items": {
412 "type": "string"
413 },
414 "type": "array"
415 }
416 },
417 "type": "object"
418 },
419 "debugAdapterPath": {
420 "description": "Path (fully qualified) to the python debug adapter executable.",
421 "type": "string"
422 },
423 "django": {
424 "default": false,
425 "description": "Django debugging.",
426 "type": "boolean"
427 },
428 "jinja": {
429 "default": null,
430 "description": "Jinja template debugging (e.g. Flask).",
431 "enum": [
432 false,
433 null,
434 true
435 ]
436 },
437 "justMyCode": {
438 "default": true,
439 "description": "If true, show and debug only user-written code. If false, show and debug all code, including library calls.",
440 "type": "boolean"
441 },
442 "logToFile": {
443 "default": false,
444 "description": "Enable logging of debugger events to a log file. This file can be found in the debugpy extension install folder.",
445 "type": "boolean"
446 },
447 "pathMappings": {
448 "default": [],
449 "items": {
450 "label": "Path mapping",
451 "properties": {
452 "localRoot": {
453 "default": "${ZED_WORKTREE_ROOT}",
454 "label": "Local source root.",
455 "type": "string"
456 },
457 "remoteRoot": {
458 "default": "",
459 "label": "Remote source root.",
460 "type": "string"
461 }
462 },
463 "required": [
464 "localRoot",
465 "remoteRoot"
466 ],
467 "type": "object"
468 },
469 "label": "Path mappings.",
470 "type": "array"
471 },
472 "redirectOutput": {
473 "default": true,
474 "description": "Redirect output.",
475 "type": "boolean"
476 },
477 "showReturnValue": {
478 "default": true,
479 "description": "Show return value of functions when stepping.",
480 "type": "boolean"
481 },
482 "subProcess": {
483 "default": false,
484 "description": "Whether to enable Sub Process debugging",
485 "type": "boolean"
486 },
487 "consoleName": {
488 "default": "Python Debug Console",
489 "description": "Display name of the debug console or terminal",
490 "type": "string"
491 },
492 "clientOS": {
493 "default": null,
494 "description": "OS that VS code is using.",
495 "enum": [
496 "windows",
497 null,
498 "unix"
499 ]
500 }
501 },
502 "required": ["request"],
503 "allOf": [
504 {
505 "if": {
506 "properties": {
507 "request": {
508 "enum": ["attach"]
509 }
510 }
511 },
512 "then": {
513 "properties": {
514 "connect": {
515 "label": "Attach by connecting to debugpy over a socket.",
516 "properties": {
517 "host": {
518 "default": "127.0.0.1",
519 "description": "Hostname or IP address to connect to.",
520 "type": "string"
521 },
522 "port": {
523 "description": "Port to connect to.",
524 "type": [
525 "number",
526 "string"
527 ]
528 }
529 },
530 "required": [
531 "port"
532 ],
533 "type": "object"
534 },
535 "listen": {
536 "label": "Attach by listening for incoming socket connection from debugpy",
537 "properties": {
538 "host": {
539 "default": "127.0.0.1",
540 "description": "Hostname or IP address of the interface to listen on.",
541 "type": "string"
542 },
543 "port": {
544 "description": "Port to listen on.",
545 "type": [
546 "number",
547 "string"
548 ]
549 }
550 },
551 "required": [
552 "port"
553 ],
554 "type": "object"
555 },
556 "processId": {
557 "anyOf": [
558 {
559 "default": "${command:pickProcess}",
560 "description": "Use process picker to select a process to attach, or Process ID as integer.",
561 "enum": [
562 "${command:pickProcess}"
563 ]
564 },
565 {
566 "description": "ID of the local process to attach to.",
567 "type": "integer"
568 }
569 ]
570 }
571 }
572 }
573 },
574 {
575 "if": {
576 "properties": {
577 "request": {
578 "enum": ["launch"]
579 }
580 }
581 },
582 "then": {
583 "properties": {
584 "args": {
585 "default": [],
586 "description": "Command line arguments passed to the program. For string type arguments, it will pass through the shell as is, and therefore all shell variable expansions will apply. But for the array type, the values will be shell-escaped.",
587 "items": {
588 "type": "string"
589 },
590 "anyOf": [
591 {
592 "default": "${command:pickArgs}",
593 "enum": [
594 "${command:pickArgs}"
595 ]
596 },
597 {
598 "type": [
599 "array",
600 "string"
601 ]
602 }
603 ]
604 },
605 "console": {
606 "default": "integratedTerminal",
607 "description": "Where to launch the debug target: internal console, integrated terminal, or external terminal.",
608 "enum": [
609 "externalTerminal",
610 "integratedTerminal",
611 "internalConsole"
612 ]
613 },
614 "cwd": {
615 "default": "${ZED_WORKTREE_ROOT}",
616 "description": "Absolute path to the working directory of the program being debugged. Default is the root directory of the file (leave empty).",
617 "type": "string"
618 },
619 "autoStartBrowser": {
620 "default": false,
621 "description": "Open external browser to launch the application",
622 "type": "boolean"
623 },
624 "env": {
625 "additionalProperties": {
626 "type": "string"
627 },
628 "default": {},
629 "description": "Environment variables defined as a key value pair. Property ends up being the Environment Variable and the value of the property ends up being the value of the Env Variable.",
630 "type": "object"
631 },
632 "envFile": {
633 "default": "${ZED_WORKTREE_ROOT}/.env",
634 "description": "Absolute path to a file containing environment variable definitions.",
635 "type": "string"
636 },
637 "gevent": {
638 "default": false,
639 "description": "Enable debugging of gevent monkey-patched code.",
640 "type": "boolean"
641 },
642 "module": {
643 "default": "",
644 "description": "Name of the module to be debugged.",
645 "type": "string"
646 },
647 "program": {
648 "default": "${ZED_FILE}",
649 "description": "Absolute path to the program.",
650 "type": "string"
651 },
652 "purpose": {
653 "default": [],
654 "description": "Tells extension to use this configuration for test debugging, or when using debug-in-terminal command.",
655 "items": {
656 "enum": [
657 "debug-test",
658 "debug-in-terminal"
659 ],
660 "enumDescriptions": [
661 "Use this configuration while debugging tests using test view or test debug commands.",
662 "Use this configuration while debugging a file using debug in terminal button in the editor."
663 ]
664 },
665 "type": "array"
666 },
667 "pyramid": {
668 "default": false,
669 "description": "Whether debugging Pyramid applications.",
670 "type": "boolean"
671 },
672 "python": {
673 "default": "${command:python.interpreterPath}",
674 "description": "Absolute path to the Python interpreter executable; overrides workspace configuration if set.",
675 "type": "string"
676 },
677 "pythonArgs": {
678 "default": [],
679 "description": "Command-line arguments passed to the Python interpreter. To pass arguments to the debug target, use \"args\".",
680 "items": {
681 "type": "string"
682 },
683 "type": "array"
684 },
685 "stopOnEntry": {
686 "default": false,
687 "description": "Automatically stop after launch.",
688 "type": "boolean"
689 },
690 "sudo": {
691 "default": false,
692 "description": "Running debug program under elevated permissions (on Unix).",
693 "type": "boolean"
694 },
695 "guiEventLoop": {
696 "default": "matplotlib",
697 "description": "The GUI event loop that's going to run. Possible values: \"matplotlib\", \"wx\", \"qt\", \"none\", or a custom function that'll be imported and run.",
698 "type": "string"
699 }
700 }
701 }
702 }
703 ]
704 })
705 }
706
707 async fn get_binary(
708 &self,
709 delegate: &Arc<dyn DapDelegate>,
710 config: &DebugTaskDefinition,
711 user_installed_path: Option<PathBuf>,
712 user_args: Option<Vec<String>>,
713 cx: &mut AsyncApp,
714 ) -> Result<DebugAdapterBinary> {
715 if let Some(local_path) = &user_installed_path {
716 log::debug!(
717 "Using user-installed debugpy adapter from: {}",
718 local_path.display()
719 );
720 return self
721 .get_installed_binary(delegate, config, Some(local_path.clone()), user_args, None)
722 .await;
723 }
724
725 let base_path = config
726 .config
727 .get("cwd")
728 .and_then(|cwd| {
729 cwd.as_str()
730 .map(Path::new)?
731 .strip_prefix(delegate.worktree_root_path())
732 .ok()
733 })
734 .unwrap_or_else(|| "".as_ref())
735 .into();
736 let toolchain = delegate
737 .toolchain_store()
738 .active_toolchain(
739 delegate.worktree_id(),
740 base_path,
741 language::LanguageName::new(Self::LANGUAGE_NAME),
742 cx,
743 )
744 .await;
745
746 let debugpy_path = self
747 .fetch_debugpy_whl(delegate)
748 .await
749 .map_err(|e| anyhow::anyhow!("{e}"))?;
750 if let Some(toolchain) = &toolchain {
751 log::debug!(
752 "Found debugpy in toolchain environment: {}",
753 debugpy_path.display()
754 );
755 return self
756 .get_installed_binary(
757 delegate,
758 config,
759 None,
760 user_args,
761 Some(toolchain.path.to_string()),
762 )
763 .await;
764 }
765
766 self.get_installed_binary(delegate, config, None, user_args, None)
767 .await
768 }
769
770 fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option<String> {
771 let label = args
772 .configuration
773 .get("name")?
774 .as_str()
775 .filter(|label| !label.is_empty())?;
776 Some(label.to_owned())
777 }
778}
779
780#[cfg(test)]
781mod tests {
782 use util::path;
783
784 use super::*;
785 use std::{net::Ipv4Addr, path::PathBuf};
786
787 #[gpui::test]
788 async fn test_debugpy_install_path_cases() {
789 let host = Ipv4Addr::new(127, 0, 0, 1);
790 let port = 5678;
791
792 // Case 1: User-defined debugpy path (highest precedence)
793 let user_path = PathBuf::from("/custom/path/to/debugpy/src/debugpy/adapter");
794 let user_args =
795 PythonDebugAdapter::generate_debugpy_arguments(&host, port, Some(&user_path), None)
796 .await
797 .unwrap();
798
799 // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
800 let venv_args = PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, None)
801 .await
802 .unwrap();
803
804 assert_eq!(user_args[0], "/custom/path/to/debugpy/src/debugpy/adapter");
805 assert_eq!(user_args[1], "--host=127.0.0.1");
806 assert_eq!(user_args[2], "--port=5678");
807
808 let expected_suffix = path!("debug_adapters/Debugpy/debugpy/adapter");
809 assert!(venv_args[0].ends_with(expected_suffix));
810 assert_eq!(venv_args[1], "--host=127.0.0.1");
811 assert_eq!(venv_args[2], "--port=5678");
812
813 // The same cases, with arguments overridden by the user
814 let user_args = PythonDebugAdapter::generate_debugpy_arguments(
815 &host,
816 port,
817 Some(&user_path),
818 Some(vec!["foo".into()]),
819 )
820 .await
821 .unwrap();
822 let venv_args = PythonDebugAdapter::generate_debugpy_arguments(
823 &host,
824 port,
825 None,
826 Some(vec!["foo".into()]),
827 )
828 .await
829 .unwrap();
830
831 assert!(user_args[0].ends_with("src/debugpy/adapter"));
832 assert_eq!(user_args[1], "foo");
833
834 assert!(venv_args[0].ends_with(expected_suffix));
835 assert_eq!(venv_args[1], "foo");
836
837 // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
838 }
839}