1use crate::*;
2use anyhow::{Context as _, bail};
3use collections::HashMap;
4use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
5use fs::RemoveOptions;
6use futures::{StreamExt, TryStreamExt};
7use gpui::http_client::AsyncBody;
8use gpui::{AsyncApp, SharedString};
9use json_dotpath::DotPaths;
10use language::{LanguageName, Toolchain};
11use paths::debug_adapters_dir;
12use serde_json::Value;
13use smol::fs::File;
14use smol::io::AsyncReadExt;
15use smol::lock::OnceCell;
16use std::ffi::OsString;
17use std::net::Ipv4Addr;
18use std::str::FromStr;
19use std::{
20 ffi::OsStr,
21 path::{Path, PathBuf},
22};
23use util::command::new_smol_command;
24use util::{ResultExt, paths::PathStyle, rel_path::RelPath};
25
26enum DebugpyLaunchMode<'a> {
27 Normal,
28 AttachWithConnect { host: Option<&'a str> },
29}
30
31#[derive(Default)]
32pub(crate) struct PythonDebugAdapter {
33 base_venv_path: OnceCell<Result<Arc<Path>, String>>,
34 debugpy_whl_base_path: OnceCell<Result<Arc<Path>, String>>,
35}
36
37impl PythonDebugAdapter {
38 const ADAPTER_NAME: &'static str = "Debugpy";
39 const DEBUG_ADAPTER_NAME: DebugAdapterName =
40 DebugAdapterName(SharedString::new_static(Self::ADAPTER_NAME));
41
42 const LANGUAGE_NAME: &'static str = "Python";
43
44 async fn generate_debugpy_arguments<'a>(
45 host: &'a Ipv4Addr,
46 port: u16,
47 launch_mode: DebugpyLaunchMode<'a>,
48 user_installed_path: Option<&'a Path>,
49 user_args: Option<Vec<String>>,
50 ) -> Result<Vec<String>> {
51 let mut args = if let Some(user_installed_path) = user_installed_path {
52 log::debug!(
53 "Using user-installed debugpy adapter from: {}",
54 user_installed_path.display()
55 );
56 vec![user_installed_path.to_string_lossy().into_owned()]
57 } else {
58 let adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
59 let path = adapter_path
60 .join("debugpy")
61 .join("adapter")
62 .to_string_lossy()
63 .into_owned();
64 log::debug!("Using pip debugpy adapter from: {path}");
65 vec![path]
66 };
67
68 args.extend(if let Some(args) = user_args {
69 args
70 } else {
71 match launch_mode {
72 DebugpyLaunchMode::Normal => {
73 vec![format!("--host={}", host), format!("--port={}", port)]
74 }
75 DebugpyLaunchMode::AttachWithConnect { host } => {
76 let mut args = vec!["connect".to_string()];
77
78 if let Some(host) = host {
79 args.push(format!("{host}:"));
80 }
81 args.push(format!("{port}"));
82 args
83 }
84 }
85 });
86 Ok(args)
87 }
88
89 async fn request_args(
90 &self,
91 delegate: &Arc<dyn DapDelegate>,
92 task_definition: &DebugTaskDefinition,
93 ) -> Result<StartDebuggingRequestArguments> {
94 let request = self.request_kind(&task_definition.config).await?;
95
96 let mut configuration = task_definition.config.clone();
97 if let Ok(console) = configuration.dot_get_mut("console") {
98 // Use built-in Zed terminal if user did not explicitly provide a setting for console.
99 if console.is_null() {
100 *console = Value::String("integratedTerminal".into());
101 }
102 }
103
104 if let Some(obj) = configuration.as_object_mut() {
105 obj.entry("cwd")
106 .or_insert(delegate.worktree_root_path().to_string_lossy().into());
107 }
108
109 Ok(StartDebuggingRequestArguments {
110 configuration,
111 request,
112 })
113 }
114
115 async fn fetch_wheel(
116 &self,
117 toolchain: Option<Toolchain>,
118 delegate: &Arc<dyn DapDelegate>,
119 ) -> Result<Arc<Path>> {
120 let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME).join("wheels");
121 std::fs::create_dir_all(&download_dir)?;
122 let venv_python = self.base_venv_path(toolchain, delegate).await?;
123
124 let installation_succeeded = util::command::new_smol_command(venv_python.as_ref())
125 .args([
126 "-m",
127 "pip",
128 "download",
129 "debugpy",
130 "--only-binary=:all:",
131 "-d",
132 download_dir.to_string_lossy().as_ref(),
133 ])
134 .output()
135 .await
136 .context("spawn system python")?
137 .status
138 .success();
139 if !installation_succeeded {
140 bail!("debugpy installation failed (could not fetch Debugpy's wheel)");
141 }
142
143 let wheel_path = std::fs::read_dir(&download_dir)?
144 .find_map(|entry| {
145 entry.ok().filter(|e| {
146 e.file_type().is_ok_and(|typ| typ.is_file())
147 && Path::new(&e.file_name()).extension() == Some("whl".as_ref())
148 })
149 })
150 .with_context(|| format!("Did not find a .whl in {download_dir:?}"))?;
151
152 util::archive::extract_zip(
153 &debug_adapters_dir().join(Self::ADAPTER_NAME),
154 File::open(&wheel_path.path()).await?,
155 )
156 .await?;
157
158 Ok(Arc::from(wheel_path.path()))
159 }
160
161 async fn maybe_fetch_new_wheel(
162 &self,
163 toolchain: Option<Toolchain>,
164 delegate: &Arc<dyn DapDelegate>,
165 ) -> Result<()> {
166 let latest_release = delegate
167 .http_client()
168 .get(
169 "https://pypi.org/pypi/debugpy/json",
170 AsyncBody::empty(),
171 false,
172 )
173 .await
174 .log_err();
175 let response = latest_release
176 .filter(|response| response.status().is_success())
177 .context("getting latest release")?;
178
179 let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME);
180 std::fs::create_dir_all(&download_dir)?;
181
182 let mut output = String::new();
183 response.into_body().read_to_string(&mut output).await?;
184 let as_json = serde_json::Value::from_str(&output)?;
185 let latest_version = as_json
186 .get("info")
187 .and_then(|info| {
188 info.get("version")
189 .and_then(|version| version.as_str())
190 .map(ToOwned::to_owned)
191 })
192 .context("parsing latest release information")?;
193 let dist_info_dirname: OsString = format!("debugpy-{latest_version}.dist-info").into();
194 let is_up_to_date = delegate
195 .fs()
196 .read_dir(&debug_adapters_dir().join(Self::ADAPTER_NAME))
197 .await?
198 .into_stream()
199 .any(async |entry| {
200 entry.is_ok_and(|e| e.file_name().is_some_and(|name| name == dist_info_dirname))
201 })
202 .await;
203
204 if !is_up_to_date {
205 delegate
206 .fs()
207 .remove_dir(
208 &debug_adapters_dir().join(Self::ADAPTER_NAME),
209 RemoveOptions {
210 recursive: true,
211 ignore_if_not_exists: true,
212 },
213 )
214 .await?;
215 self.fetch_wheel(toolchain, delegate).await?;
216 }
217 anyhow::Ok(())
218 }
219
220 async fn fetch_debugpy_whl(
221 &self,
222 toolchain: Option<Toolchain>,
223 delegate: &Arc<dyn DapDelegate>,
224 ) -> Result<Arc<Path>, String> {
225 self.debugpy_whl_base_path
226 .get_or_init(|| async move {
227 self.maybe_fetch_new_wheel(toolchain, delegate)
228 .await
229 .map_err(|e| format!("{e}"))?;
230 Ok(Arc::from(
231 debug_adapters_dir()
232 .join(Self::ADAPTER_NAME)
233 .join("debugpy")
234 .join("adapter")
235 .as_ref(),
236 ))
237 })
238 .await
239 .clone()
240 }
241
242 async fn base_venv_path(
243 &self,
244 toolchain: Option<Toolchain>,
245 delegate: &Arc<dyn DapDelegate>,
246 ) -> Result<Arc<Path>> {
247 let result = self.base_venv_path
248 .get_or_init(|| async {
249 let base_python = if let Some(toolchain) = toolchain {
250 toolchain.path.to_string()
251 } else {
252 Self::system_python_name(delegate).await.ok_or_else(|| {
253 let mut message = "Could not find a Python installation".to_owned();
254 if cfg!(windows){
255 message.push_str(". Install Python from the Microsoft Store, or manually from https://www.python.org/downloads/windows.")
256 }
257 message
258 })?
259 };
260
261 let debug_adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
262 let output = util::command::new_smol_command(&base_python)
263 .args(["-m", "venv", "zed_base_venv"])
264 .current_dir(
265 &debug_adapter_path,
266 )
267 .spawn()
268 .map_err(|e| format!("{e:#?}"))?
269 .output()
270 .await
271 .map_err(|e| format!("{e:#?}"))?;
272
273 if !output.status.success() {
274 let stderr = String::from_utf8_lossy(&output.stderr);
275 let stdout = String::from_utf8_lossy(&output.stdout);
276 let debug_adapter_path = debug_adapter_path.display();
277 return Err(format!("Failed to create base virtual environment with {base_python} in:\n{debug_adapter_path}\nstderr:\n{stderr}\nstdout:\n{stdout}\n"));
278 }
279
280 const PYTHON_PATH: &str = if cfg!(target_os = "windows") {
281 "Scripts/python.exe"
282 } else {
283 "bin/python3"
284 };
285 Ok(Arc::from(
286 paths::debug_adapters_dir()
287 .join(Self::DEBUG_ADAPTER_NAME.as_ref())
288 .join("zed_base_venv")
289 .join(PYTHON_PATH)
290 .as_ref(),
291 ))
292 })
293 .await
294 .clone();
295 match result {
296 Ok(path) => Ok(path),
297 Err(e) => Err(anyhow::anyhow!("{e}")),
298 }
299 }
300 async fn system_python_name(delegate: &Arc<dyn DapDelegate>) -> Option<String> {
301 const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
302 let mut name = None;
303
304 for cmd in BINARY_NAMES {
305 let Some(path) = delegate.which(OsStr::new(cmd)).await else {
306 continue;
307 };
308 // Try to detect situations where `python3` exists but is not a real Python interpreter.
309 // Notably, on fresh Windows installs, `python3` is a shim that opens the Microsoft Store app
310 // when run with no arguments, and just fails otherwise.
311 let Some(output) = new_smol_command(&path)
312 .args(["-c", "print(1 + 2)"])
313 .output()
314 .await
315 .ok()
316 else {
317 continue;
318 };
319 if output.stdout.trim_ascii() != b"3" {
320 continue;
321 }
322 name = Some(path.to_string_lossy().into_owned());
323 break;
324 }
325 name
326 }
327
328 async fn get_installed_binary(
329 &self,
330 delegate: &Arc<dyn DapDelegate>,
331 config: &DebugTaskDefinition,
332 user_installed_path: Option<PathBuf>,
333 user_args: Option<Vec<String>>,
334 user_env: Option<HashMap<String, String>>,
335 python_from_toolchain: Option<String>,
336 ) -> Result<DebugAdapterBinary> {
337 let mut tcp_connection = config.tcp_connection.clone().unwrap_or_default();
338
339 let (config_port, config_host) = config
340 .config
341 .get("connect")
342 .map(|value| {
343 (
344 value
345 .get("port")
346 .and_then(|val| val.as_u64().map(|p| p as u16)),
347 value.get("host").and_then(|val| val.as_str()),
348 )
349 })
350 .unwrap_or_else(|| {
351 (
352 config
353 .config
354 .get("port")
355 .and_then(|port| port.as_u64().map(|p| p as u16)),
356 config.config.get("host").and_then(|host| host.as_str()),
357 )
358 });
359
360 let is_attach_with_connect = if config
361 .config
362 .get("request")
363 .is_some_and(|val| val.as_str().is_some_and(|request| request == "attach"))
364 {
365 if tcp_connection.host.is_some() && config_host.is_some() {
366 bail!("Cannot have two different hosts in debug configuration")
367 } else if tcp_connection.port.is_some() && config_port.is_some() {
368 bail!("Cannot have two different ports in debug configuration")
369 }
370
371 if let Some(hostname) = config_host {
372 tcp_connection.host = Some(hostname.parse().context("hostname must be IPv4")?);
373 }
374 tcp_connection.port = config_port;
375 DebugpyLaunchMode::AttachWithConnect { host: config_host }
376 } else {
377 DebugpyLaunchMode::Normal
378 };
379
380 let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
381
382 let python_path = if let Some(toolchain) = python_from_toolchain {
383 Some(toolchain)
384 } else {
385 Self::system_python_name(delegate).await
386 };
387
388 let python_command = python_path.context("failed to find binary path for Python")?;
389 log::debug!("Using Python executable: {}", python_command);
390
391 let arguments = Self::generate_debugpy_arguments(
392 &host,
393 port,
394 is_attach_with_connect,
395 user_installed_path.as_deref(),
396 user_args,
397 )
398 .await?;
399
400 log::debug!(
401 "Starting debugpy adapter with command: {} {}",
402 python_command,
403 arguments.join(" ")
404 );
405
406 Ok(DebugAdapterBinary {
407 command: Some(python_command),
408 arguments,
409 connection: Some(adapters::TcpArguments {
410 host,
411 port,
412 timeout,
413 }),
414 cwd: Some(delegate.worktree_root_path().to_path_buf()),
415 envs: user_env.unwrap_or_default(),
416 request_args: self.request_args(delegate, config).await?,
417 })
418 }
419}
420
421#[async_trait(?Send)]
422impl DebugAdapter for PythonDebugAdapter {
423 fn name(&self) -> DebugAdapterName {
424 Self::DEBUG_ADAPTER_NAME
425 }
426
427 fn adapter_language_name(&self) -> Option<LanguageName> {
428 Some(SharedString::new_static("Python").into())
429 }
430
431 async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
432 let mut args = json!({
433 "request": match zed_scenario.request {
434 DebugRequest::Launch(_) => "launch",
435 DebugRequest::Attach(_) => "attach",
436 },
437 "subProcess": true,
438 "redirectOutput": true,
439 });
440
441 let map = args.as_object_mut().unwrap();
442 match &zed_scenario.request {
443 DebugRequest::Attach(attach) => {
444 map.insert("processId".into(), attach.process_id.into());
445 }
446 DebugRequest::Launch(launch) => {
447 map.insert("program".into(), launch.program.clone().into());
448 map.insert("args".into(), launch.args.clone().into());
449 if !launch.env.is_empty() {
450 map.insert("env".into(), launch.env_json());
451 }
452
453 if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
454 map.insert("stopOnEntry".into(), stop_on_entry.into());
455 }
456 if let Some(cwd) = launch.cwd.as_ref() {
457 map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
458 }
459 }
460 }
461
462 Ok(DebugScenario {
463 adapter: zed_scenario.adapter,
464 label: zed_scenario.label,
465 config: args,
466 build: None,
467 tcp_connection: None,
468 })
469 }
470
471 fn dap_schema(&self) -> serde_json::Value {
472 json!({
473 "properties": {
474 "request": {
475 "type": "string",
476 "enum": ["attach", "launch"],
477 "description": "Debug adapter request type"
478 },
479 "autoReload": {
480 "default": {},
481 "description": "Configures automatic reload of code on edit.",
482 "properties": {
483 "enable": {
484 "default": false,
485 "description": "Automatically reload code on edit.",
486 "type": "boolean"
487 },
488 "exclude": {
489 "default": [
490 "**/.git/**",
491 "**/.metadata/**",
492 "**/__pycache__/**",
493 "**/node_modules/**",
494 "**/site-packages/**"
495 ],
496 "description": "Glob patterns of paths to exclude from auto reload.",
497 "items": {
498 "type": "string"
499 },
500 "type": "array"
501 },
502 "include": {
503 "default": [
504 "**/*.py",
505 "**/*.pyw"
506 ],
507 "description": "Glob patterns of paths to include in auto reload.",
508 "items": {
509 "type": "string"
510 },
511 "type": "array"
512 }
513 },
514 "type": "object"
515 },
516 "debugAdapterPath": {
517 "description": "Path (fully qualified) to the python debug adapter executable.",
518 "type": "string"
519 },
520 "django": {
521 "default": false,
522 "description": "Django debugging.",
523 "type": "boolean"
524 },
525 "jinja": {
526 "default": null,
527 "description": "Jinja template debugging (e.g. Flask).",
528 "enum": [
529 false,
530 null,
531 true
532 ]
533 },
534 "justMyCode": {
535 "default": true,
536 "description": "If true, show and debug only user-written code. If false, show and debug all code, including library calls.",
537 "type": "boolean"
538 },
539 "logToFile": {
540 "default": false,
541 "description": "Enable logging of debugger events to a log file. This file can be found in the debugpy extension install folder.",
542 "type": "boolean"
543 },
544 "pathMappings": {
545 "default": [],
546 "items": {
547 "label": "Path mapping",
548 "properties": {
549 "localRoot": {
550 "default": "${ZED_WORKTREE_ROOT}",
551 "label": "Local source root.",
552 "type": "string"
553 },
554 "remoteRoot": {
555 "default": "",
556 "label": "Remote source root.",
557 "type": "string"
558 }
559 },
560 "required": [
561 "localRoot",
562 "remoteRoot"
563 ],
564 "type": "object"
565 },
566 "label": "Path mappings.",
567 "type": "array"
568 },
569 "redirectOutput": {
570 "default": true,
571 "description": "Redirect output.",
572 "type": "boolean"
573 },
574 "showReturnValue": {
575 "default": true,
576 "description": "Show return value of functions when stepping.",
577 "type": "boolean"
578 },
579 "subProcess": {
580 "default": false,
581 "description": "Whether to enable Sub Process debugging",
582 "type": "boolean"
583 },
584 "consoleName": {
585 "default": "Python Debug Console",
586 "description": "Display name of the debug console or terminal",
587 "type": "string"
588 },
589 "clientOS": {
590 "default": null,
591 "description": "OS that VS code is using.",
592 "enum": [
593 "windows",
594 null,
595 "unix"
596 ]
597 }
598 },
599 "required": ["request"],
600 "allOf": [
601 {
602 "if": {
603 "properties": {
604 "request": {
605 "enum": ["attach"]
606 }
607 }
608 },
609 "then": {
610 "properties": {
611 "connect": {
612 "label": "Attach by connecting to debugpy over a socket.",
613 "properties": {
614 "host": {
615 "default": "127.0.0.1",
616 "description": "Hostname or IP address to connect to.",
617 "type": "string"
618 },
619 "port": {
620 "description": "Port to connect to.",
621 "type": [
622 "number",
623 "string"
624 ]
625 }
626 },
627 "required": [
628 "port"
629 ],
630 "type": "object"
631 },
632 "listen": {
633 "label": "Attach by listening for incoming socket connection from debugpy",
634 "properties": {
635 "host": {
636 "default": "127.0.0.1",
637 "description": "Hostname or IP address of the interface to listen on.",
638 "type": "string"
639 },
640 "port": {
641 "description": "Port to listen on.",
642 "type": [
643 "number",
644 "string"
645 ]
646 }
647 },
648 "required": [
649 "port"
650 ],
651 "type": "object"
652 },
653 "processId": {
654 "anyOf": [
655 {
656 "default": "${command:pickProcess}",
657 "description": "Use process picker to select a process to attach, or Process ID as integer.",
658 "enum": [
659 "${command:pickProcess}"
660 ]
661 },
662 {
663 "description": "ID of the local process to attach to.",
664 "type": "integer"
665 }
666 ]
667 }
668 }
669 }
670 },
671 {
672 "if": {
673 "properties": {
674 "request": {
675 "enum": ["launch"]
676 }
677 }
678 },
679 "then": {
680 "properties": {
681 "args": {
682 "default": [],
683 "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.",
684 "items": {
685 "type": "string"
686 },
687 "anyOf": [
688 {
689 "default": "${command:pickArgs}",
690 "enum": [
691 "${command:pickArgs}"
692 ]
693 },
694 {
695 "type": [
696 "array",
697 "string"
698 ]
699 }
700 ]
701 },
702 "console": {
703 "default": "integratedTerminal",
704 "description": "Where to launch the debug target: internal console, integrated terminal, or external terminal.",
705 "enum": [
706 "externalTerminal",
707 "integratedTerminal",
708 "internalConsole"
709 ]
710 },
711 "cwd": {
712 "default": "${ZED_WORKTREE_ROOT}",
713 "description": "Absolute path to the working directory of the program being debugged. Default is the root directory of the file (leave empty).",
714 "type": "string"
715 },
716 "autoStartBrowser": {
717 "default": false,
718 "description": "Open external browser to launch the application",
719 "type": "boolean"
720 },
721 "env": {
722 "additionalProperties": {
723 "type": "string"
724 },
725 "default": {},
726 "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.",
727 "type": "object"
728 },
729 "envFile": {
730 "default": "${ZED_WORKTREE_ROOT}/.env",
731 "description": "Absolute path to a file containing environment variable definitions.",
732 "type": "string"
733 },
734 "gevent": {
735 "default": false,
736 "description": "Enable debugging of gevent monkey-patched code.",
737 "type": "boolean"
738 },
739 "module": {
740 "default": "",
741 "description": "Name of the module to be debugged.",
742 "type": "string"
743 },
744 "program": {
745 "default": "${ZED_FILE}",
746 "description": "Absolute path to the program.",
747 "type": "string"
748 },
749 "purpose": {
750 "default": [],
751 "description": "Tells extension to use this configuration for test debugging, or when using debug-in-terminal command.",
752 "items": {
753 "enum": [
754 "debug-test",
755 "debug-in-terminal"
756 ],
757 "enumDescriptions": [
758 "Use this configuration while debugging tests using test view or test debug commands.",
759 "Use this configuration while debugging a file using debug in terminal button in the editor."
760 ]
761 },
762 "type": "array"
763 },
764 "pyramid": {
765 "default": false,
766 "description": "Whether debugging Pyramid applications.",
767 "type": "boolean"
768 },
769 "python": {
770 "default": "${command:python.interpreterPath}",
771 "description": "Absolute path to the Python interpreter executable; overrides workspace configuration if set.",
772 "type": "string"
773 },
774 "pythonArgs": {
775 "default": [],
776 "description": "Command-line arguments passed to the Python interpreter. To pass arguments to the debug target, use \"args\".",
777 "items": {
778 "type": "string"
779 },
780 "type": "array"
781 },
782 "stopOnEntry": {
783 "default": false,
784 "description": "Automatically stop after launch.",
785 "type": "boolean"
786 },
787 "sudo": {
788 "default": false,
789 "description": "Running debug program under elevated permissions (on Unix).",
790 "type": "boolean"
791 },
792 "guiEventLoop": {
793 "default": "matplotlib",
794 "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.",
795 "type": "string"
796 }
797 }
798 }
799 }
800 ]
801 })
802 }
803
804 async fn get_binary(
805 &self,
806 delegate: &Arc<dyn DapDelegate>,
807 config: &DebugTaskDefinition,
808 user_installed_path: Option<PathBuf>,
809 user_args: Option<Vec<String>>,
810 user_env: Option<HashMap<String, String>>,
811 cx: &mut AsyncApp,
812 ) -> Result<DebugAdapterBinary> {
813 if let Some(local_path) = &user_installed_path {
814 log::debug!(
815 "Using user-installed debugpy adapter from: {}",
816 local_path.display()
817 );
818 return self
819 .get_installed_binary(
820 delegate,
821 config,
822 Some(local_path.clone()),
823 user_args,
824 user_env,
825 None,
826 )
827 .await;
828 }
829
830 let base_paths = ["cwd", "program", "module"]
831 .into_iter()
832 .filter_map(|key| {
833 config.config.get(key).and_then(|cwd| {
834 RelPath::new(
835 cwd.as_str()
836 .map(Path::new)?
837 .strip_prefix(delegate.worktree_root_path())
838 .ok()?,
839 PathStyle::local(),
840 )
841 .ok()
842 })
843 })
844 .chain(
845 // While Debugpy's wiki saids absolute paths are required, but it actually supports relative paths when cwd is passed in.
846 // (Which should always be the case because Zed defaults to the cwd worktree root)
847 // So we want to check that these relative paths find toolchains as well. Otherwise, they won't be checked
848 // because the strip prefix in the iteration above will return an error
849 config
850 .config
851 .get("cwd")
852 .map(|_| {
853 ["program", "module"].into_iter().filter_map(|key| {
854 config.config.get(key).and_then(|value| {
855 let path = Path::new(value.as_str()?);
856 RelPath::new(path, PathStyle::local()).ok()
857 })
858 })
859 })
860 .into_iter()
861 .flatten(),
862 )
863 .chain([RelPath::empty().into()]);
864
865 let mut toolchain = None;
866
867 for base_path in base_paths {
868 if let Some(found_toolchain) = delegate
869 .toolchain_store()
870 .active_toolchain(
871 delegate.worktree_id(),
872 base_path.into_arc(),
873 language::LanguageName::new(Self::LANGUAGE_NAME),
874 cx,
875 )
876 .await
877 {
878 toolchain = Some(found_toolchain);
879 break;
880 }
881 }
882
883 self.fetch_debugpy_whl(toolchain.clone(), delegate)
884 .await
885 .map_err(|e| anyhow::anyhow!("{e}"))?;
886 if let Some(toolchain) = &toolchain {
887 return self
888 .get_installed_binary(
889 delegate,
890 config,
891 None,
892 user_args,
893 user_env,
894 Some(toolchain.path.to_string()),
895 )
896 .await;
897 }
898
899 self.get_installed_binary(delegate, config, None, user_args, user_env, None)
900 .await
901 }
902
903 fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option<String> {
904 let label = args
905 .configuration
906 .get("name")?
907 .as_str()
908 .filter(|label| !label.is_empty())?;
909 Some(label.to_owned())
910 }
911}
912
913#[cfg(test)]
914mod tests {
915 use util::path;
916
917 use super::*;
918 use task::TcpArgumentsTemplate;
919
920 #[gpui::test]
921 async fn test_tcp_connection_conflict_with_connect_args() {
922 let adapter = PythonDebugAdapter {
923 base_venv_path: OnceCell::new(),
924 debugpy_whl_base_path: OnceCell::new(),
925 };
926
927 let config_with_port_conflict = json!({
928 "request": "attach",
929 "connect": {
930 "port": 5679
931 }
932 });
933
934 let tcp_connection = TcpArgumentsTemplate {
935 host: None,
936 port: Some(5678),
937 timeout: None,
938 };
939
940 let task_def = DebugTaskDefinition {
941 label: "test".into(),
942 adapter: PythonDebugAdapter::ADAPTER_NAME.into(),
943 config: config_with_port_conflict,
944 tcp_connection: Some(tcp_connection.clone()),
945 };
946
947 let result = adapter
948 .get_installed_binary(
949 &test_mocks::MockDelegate::new(),
950 &task_def,
951 None,
952 None,
953 None,
954 Some("python3".to_string()),
955 )
956 .await;
957
958 assert!(result.is_err());
959 assert!(
960 result
961 .unwrap_err()
962 .to_string()
963 .contains("Cannot have two different ports")
964 );
965
966 let host = Ipv4Addr::new(127, 0, 0, 1);
967 let config_with_host_conflict = json!({
968 "request": "attach",
969 "connect": {
970 "host": "192.168.1.1",
971 "port": 5678
972 }
973 });
974
975 let tcp_connection_with_host = TcpArgumentsTemplate {
976 host: Some(host),
977 port: None,
978 timeout: None,
979 };
980
981 let task_def_host = DebugTaskDefinition {
982 label: "test".into(),
983 adapter: PythonDebugAdapter::ADAPTER_NAME.into(),
984 config: config_with_host_conflict,
985 tcp_connection: Some(tcp_connection_with_host),
986 };
987
988 let result_host = adapter
989 .get_installed_binary(
990 &test_mocks::MockDelegate::new(),
991 &task_def_host,
992 None,
993 None,
994 None,
995 Some("python3".to_string()),
996 )
997 .await;
998
999 assert!(result_host.is_err());
1000 assert!(
1001 result_host
1002 .unwrap_err()
1003 .to_string()
1004 .contains("Cannot have two different hosts")
1005 );
1006 }
1007
1008 #[gpui::test]
1009 async fn test_attach_with_connect_mode_generates_correct_arguments() {
1010 let host = Ipv4Addr::new(127, 0, 0, 1);
1011 let port = 5678;
1012
1013 let args_without_host = PythonDebugAdapter::generate_debugpy_arguments(
1014 &host,
1015 port,
1016 DebugpyLaunchMode::AttachWithConnect { host: None },
1017 None,
1018 None,
1019 )
1020 .await
1021 .unwrap();
1022
1023 let expected_suffix = path!("debug_adapters/Debugpy/debugpy/adapter");
1024 assert!(args_without_host[0].ends_with(expected_suffix));
1025 assert_eq!(args_without_host[1], "connect");
1026 assert_eq!(args_without_host[2], "5678");
1027
1028 let args_with_host = PythonDebugAdapter::generate_debugpy_arguments(
1029 &host,
1030 port,
1031 DebugpyLaunchMode::AttachWithConnect {
1032 host: Some("192.168.1.100"),
1033 },
1034 None,
1035 None,
1036 )
1037 .await
1038 .unwrap();
1039
1040 assert!(args_with_host[0].ends_with(expected_suffix));
1041 assert_eq!(args_with_host[1], "connect");
1042 assert_eq!(args_with_host[2], "192.168.1.100:");
1043 assert_eq!(args_with_host[3], "5678");
1044
1045 let args_normal = PythonDebugAdapter::generate_debugpy_arguments(
1046 &host,
1047 port,
1048 DebugpyLaunchMode::Normal,
1049 None,
1050 None,
1051 )
1052 .await
1053 .unwrap();
1054
1055 assert!(args_normal[0].ends_with(expected_suffix));
1056 assert_eq!(args_normal[1], "--host=127.0.0.1");
1057 assert_eq!(args_normal[2], "--port=5678");
1058 assert!(!args_normal.contains(&"connect".to_string()));
1059 }
1060
1061 #[gpui::test]
1062 async fn test_debugpy_install_path_cases() {
1063 let host = Ipv4Addr::new(127, 0, 0, 1);
1064 let port = 5678;
1065
1066 // Case 1: User-defined debugpy path (highest precedence)
1067 let user_path = PathBuf::from("/custom/path/to/debugpy/src/debugpy/adapter");
1068 let user_args = PythonDebugAdapter::generate_debugpy_arguments(
1069 &host,
1070 port,
1071 DebugpyLaunchMode::Normal,
1072 Some(&user_path),
1073 None,
1074 )
1075 .await
1076 .unwrap();
1077
1078 let venv_args = PythonDebugAdapter::generate_debugpy_arguments(
1079 &host,
1080 port,
1081 DebugpyLaunchMode::Normal,
1082 None,
1083 None,
1084 )
1085 .await
1086 .unwrap();
1087
1088 assert_eq!(user_args[0], "/custom/path/to/debugpy/src/debugpy/adapter");
1089 assert_eq!(user_args[1], "--host=127.0.0.1");
1090 assert_eq!(user_args[2], "--port=5678");
1091
1092 let expected_suffix = path!("debug_adapters/Debugpy/debugpy/adapter");
1093 assert!(venv_args[0].ends_with(expected_suffix));
1094 assert_eq!(venv_args[1], "--host=127.0.0.1");
1095 assert_eq!(venv_args[2], "--port=5678");
1096
1097 // The same cases, with arguments overridden by the user
1098 let user_args = PythonDebugAdapter::generate_debugpy_arguments(
1099 &host,
1100 port,
1101 DebugpyLaunchMode::Normal,
1102 Some(&user_path),
1103 Some(vec!["foo".into()]),
1104 )
1105 .await
1106 .unwrap();
1107 let venv_args = PythonDebugAdapter::generate_debugpy_arguments(
1108 &host,
1109 port,
1110 DebugpyLaunchMode::Normal,
1111 None,
1112 Some(vec!["foo".into()]),
1113 )
1114 .await
1115 .unwrap();
1116
1117 assert!(user_args[0].ends_with("src/debugpy/adapter"));
1118 assert_eq!(user_args[1], "foo");
1119
1120 assert!(venv_args[0].ends_with(expected_suffix));
1121 assert_eq!(venv_args[1], "foo");
1122
1123 // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
1124 }
1125}