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