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