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