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