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