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