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