python.rs

  1use crate::*;
  2use anyhow::Context as _;
  3use dap::adapters::latest_github_release;
  4use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
  5use gpui::{AppContext, AsyncApp, SharedString};
  6use json_dotpath::DotPaths;
  7use language::{LanguageName, Toolchain};
  8use serde_json::Value;
  9use std::net::Ipv4Addr;
 10use std::{
 11    collections::HashMap,
 12    ffi::OsStr,
 13    path::{Path, PathBuf},
 14    sync::OnceLock,
 15};
 16use util::ResultExt;
 17
 18#[derive(Default)]
 19pub(crate) struct PythonDebugAdapter {
 20    checked: OnceLock<()>,
 21}
 22
 23impl PythonDebugAdapter {
 24    const ADAPTER_NAME: &'static str = "Debugpy";
 25    const DEBUG_ADAPTER_NAME: DebugAdapterName =
 26        DebugAdapterName(SharedString::new_static(Self::ADAPTER_NAME));
 27    const ADAPTER_PACKAGE_NAME: &'static str = "debugpy";
 28    const ADAPTER_PATH: &'static str = "src/debugpy/adapter";
 29    const LANGUAGE_NAME: &'static str = "Python";
 30
 31    async fn generate_debugpy_arguments(
 32        host: &Ipv4Addr,
 33        port: u16,
 34        user_installed_path: Option<&Path>,
 35        installed_in_venv: bool,
 36    ) -> Result<Vec<String>> {
 37        if let Some(user_installed_path) = user_installed_path {
 38            log::debug!(
 39                "Using user-installed debugpy adapter from: {}",
 40                user_installed_path.display()
 41            );
 42            Ok(vec![
 43                user_installed_path
 44                    .join(Self::ADAPTER_PATH)
 45                    .to_string_lossy()
 46                    .to_string(),
 47                format!("--host={}", host),
 48                format!("--port={}", port),
 49            ])
 50        } else if installed_in_venv {
 51            log::debug!("Using venv-installed debugpy");
 52            Ok(vec![
 53                "-m".to_string(),
 54                "debugpy.adapter".to_string(),
 55                format!("--host={}", host),
 56                format!("--port={}", port),
 57            ])
 58        } else {
 59            let adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
 60            let file_name_prefix = format!("{}_", Self::ADAPTER_NAME);
 61
 62            let debugpy_dir =
 63                util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
 64                    file_name.starts_with(&file_name_prefix)
 65                })
 66                .await
 67                .context("Debugpy directory not found")?;
 68
 69            log::debug!(
 70                "Using GitHub-downloaded debugpy adapter from: {}",
 71                debugpy_dir.display()
 72            );
 73            Ok(vec![
 74                debugpy_dir
 75                    .join(Self::ADAPTER_PATH)
 76                    .to_string_lossy()
 77                    .to_string(),
 78                format!("--host={}", host),
 79                format!("--port={}", port),
 80            ])
 81        }
 82    }
 83
 84    fn request_args(
 85        &self,
 86        delegate: &Arc<dyn DapDelegate>,
 87        task_definition: &DebugTaskDefinition,
 88    ) -> Result<StartDebuggingRequestArguments> {
 89        let request = self.request_kind(&task_definition.config)?;
 90
 91        let mut configuration = task_definition.config.clone();
 92        if let Ok(console) = configuration.dot_get_mut("console") {
 93            // Use built-in Zed terminal if user did not explicitly provide a setting for console.
 94            if console.is_null() {
 95                *console = Value::String("integratedTerminal".into());
 96            }
 97        }
 98
 99        if let Some(obj) = configuration.as_object_mut() {
100            obj.entry("cwd")
101                .or_insert(delegate.worktree_root_path().to_string_lossy().into());
102        }
103
104        Ok(StartDebuggingRequestArguments {
105            configuration,
106            request,
107        })
108    }
109    async fn fetch_latest_adapter_version(
110        &self,
111        delegate: &Arc<dyn DapDelegate>,
112    ) -> Result<AdapterVersion> {
113        let github_repo = GithubRepo {
114            repo_name: Self::ADAPTER_PACKAGE_NAME.into(),
115            repo_owner: "microsoft".into(),
116        };
117
118        fetch_latest_adapter_version_from_github(github_repo, delegate.as_ref()).await
119    }
120
121    async fn install_binary(
122        adapter_name: DebugAdapterName,
123        version: AdapterVersion,
124        delegate: Arc<dyn DapDelegate>,
125    ) -> Result<()> {
126        let version_path = adapters::download_adapter_from_github(
127            adapter_name,
128            version,
129            adapters::DownloadedFileType::GzipTar,
130            delegate.as_ref(),
131        )
132        .await?;
133        // only needed when you install the latest version for the first time
134        if let Some(debugpy_dir) =
135            util::fs::find_file_name_in_dir(version_path.as_path(), |file_name| {
136                file_name.starts_with("microsoft-debugpy-")
137            })
138            .await
139        {
140            // TODO Debugger: Rename folder instead of moving all files to another folder
141            // We're doing unnecessary IO work right now
142            util::fs::move_folder_files_to_folder(debugpy_dir.as_path(), version_path.as_path())
143                .await?;
144        }
145
146        Ok(())
147    }
148
149    async fn get_installed_binary(
150        &self,
151        delegate: &Arc<dyn DapDelegate>,
152        config: &DebugTaskDefinition,
153        user_installed_path: Option<PathBuf>,
154        toolchain: Option<Toolchain>,
155        installed_in_venv: bool,
156    ) -> Result<DebugAdapterBinary> {
157        const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
158        let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
159        let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
160
161        let python_path = if let Some(toolchain) = toolchain {
162            Some(toolchain.path.to_string())
163        } else {
164            let mut name = None;
165
166            for cmd in BINARY_NAMES {
167                name = delegate
168                    .which(OsStr::new(cmd))
169                    .await
170                    .map(|path| path.to_string_lossy().to_string());
171                if name.is_some() {
172                    break;
173                }
174            }
175            name
176        };
177
178        let python_command = python_path.context("failed to find binary path for Python")?;
179        log::debug!("Using Python executable: {}", python_command);
180
181        let arguments = Self::generate_debugpy_arguments(
182            &host,
183            port,
184            user_installed_path.as_deref(),
185            installed_in_venv,
186        )
187        .await?;
188
189        log::debug!(
190            "Starting debugpy adapter with command: {} {}",
191            python_command,
192            arguments.join(" ")
193        );
194
195        Ok(DebugAdapterBinary {
196            command: Some(python_command),
197            arguments,
198            connection: Some(adapters::TcpArguments {
199                host,
200                port,
201                timeout,
202            }),
203            cwd: Some(delegate.worktree_root_path().to_path_buf()),
204            envs: HashMap::default(),
205            request_args: self.request_args(delegate, config)?,
206        })
207    }
208}
209
210#[async_trait(?Send)]
211impl DebugAdapter for PythonDebugAdapter {
212    fn name(&self) -> DebugAdapterName {
213        Self::DEBUG_ADAPTER_NAME
214    }
215
216    fn adapter_language_name(&self) -> Option<LanguageName> {
217        Some(SharedString::new_static("Python").into())
218    }
219
220    fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
221        let mut args = json!({
222            "request": match zed_scenario.request {
223                DebugRequest::Launch(_) => "launch",
224                DebugRequest::Attach(_) => "attach",
225            },
226            "subProcess": true,
227            "redirectOutput": true,
228        });
229
230        let map = args.as_object_mut().unwrap();
231        match &zed_scenario.request {
232            DebugRequest::Attach(attach) => {
233                map.insert("processId".into(), attach.process_id.into());
234            }
235            DebugRequest::Launch(launch) => {
236                map.insert("program".into(), launch.program.clone().into());
237                map.insert("args".into(), launch.args.clone().into());
238                if !launch.env.is_empty() {
239                    map.insert("env".into(), launch.env_json());
240                }
241
242                if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
243                    map.insert("stopOnEntry".into(), stop_on_entry.into());
244                }
245                if let Some(cwd) = launch.cwd.as_ref() {
246                    map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
247                }
248            }
249        }
250
251        Ok(DebugScenario {
252            adapter: zed_scenario.adapter,
253            label: zed_scenario.label,
254            config: args,
255            build: None,
256            tcp_connection: None,
257        })
258    }
259
260    fn dap_schema(&self) -> serde_json::Value {
261        json!({
262            "properties": {
263                "request": {
264                    "type": "string",
265                    "enum": ["attach", "launch"],
266                    "description": "Debug adapter request type"
267                },
268                "autoReload": {
269                    "default": {},
270                    "description": "Configures automatic reload of code on edit.",
271                    "properties": {
272                        "enable": {
273                            "default": false,
274                            "description": "Automatically reload code on edit.",
275                            "type": "boolean"
276                        },
277                        "exclude": {
278                            "default": [
279                                "**/.git/**",
280                                "**/.metadata/**",
281                                "**/__pycache__/**",
282                                "**/node_modules/**",
283                                "**/site-packages/**"
284                            ],
285                            "description": "Glob patterns of paths to exclude from auto reload.",
286                            "items": {
287                                "type": "string"
288                            },
289                            "type": "array"
290                        },
291                        "include": {
292                            "default": [
293                                "**/*.py",
294                                "**/*.pyw"
295                            ],
296                            "description": "Glob patterns of paths to include in auto reload.",
297                            "items": {
298                                "type": "string"
299                            },
300                            "type": "array"
301                        }
302                    },
303                    "type": "object"
304                },
305                "debugAdapterPath": {
306                    "description": "Path (fully qualified) to the python debug adapter executable.",
307                    "type": "string"
308                },
309                "django": {
310                    "default": false,
311                    "description": "Django debugging.",
312                    "type": "boolean"
313                },
314                "jinja": {
315                    "default": null,
316                    "description": "Jinja template debugging (e.g. Flask).",
317                    "enum": [
318                        false,
319                        null,
320                        true
321                    ]
322                },
323                "justMyCode": {
324                    "default": true,
325                    "description": "If true, show and debug only user-written code. If false, show and debug all code, including library calls.",
326                    "type": "boolean"
327                },
328                "logToFile": {
329                    "default": false,
330                    "description": "Enable logging of debugger events to a log file. This file can be found in the debugpy extension install folder.",
331                    "type": "boolean"
332                },
333                "pathMappings": {
334                    "default": [],
335                    "items": {
336                        "label": "Path mapping",
337                        "properties": {
338                            "localRoot": {
339                                "default": "${ZED_WORKTREE_ROOT}",
340                                "label": "Local source root.",
341                                "type": "string"
342                            },
343                            "remoteRoot": {
344                                "default": "",
345                                "label": "Remote source root.",
346                                "type": "string"
347                            }
348                        },
349                        "required": [
350                            "localRoot",
351                            "remoteRoot"
352                        ],
353                        "type": "object"
354                    },
355                    "label": "Path mappings.",
356                    "type": "array"
357                },
358                "redirectOutput": {
359                    "default": true,
360                    "description": "Redirect output.",
361                    "type": "boolean"
362                },
363                "showReturnValue": {
364                    "default": true,
365                    "description": "Show return value of functions when stepping.",
366                    "type": "boolean"
367                },
368                "subProcess": {
369                    "default": false,
370                    "description": "Whether to enable Sub Process debugging",
371                    "type": "boolean"
372                },
373                "consoleName": {
374                    "default": "Python Debug Console",
375                    "description": "Display name of the debug console or terminal",
376                    "type": "string"
377                },
378                "clientOS": {
379                    "default": null,
380                    "description": "OS that VS code is using.",
381                    "enum": [
382                        "windows",
383                        null,
384                        "unix"
385                    ]
386                }
387            },
388            "required": ["request"],
389            "allOf": [
390                {
391                    "if": {
392                        "properties": {
393                            "request": {
394                                "enum": ["attach"]
395                            }
396                        }
397                    },
398                    "then": {
399                        "properties": {
400                            "connect": {
401                                "label": "Attach by connecting to debugpy over a socket.",
402                                "properties": {
403                                    "host": {
404                                        "default": "127.0.0.1",
405                                        "description": "Hostname or IP address to connect to.",
406                                        "type": "string"
407                                    },
408                                    "port": {
409                                        "description": "Port to connect to.",
410                                        "type": [
411                                            "number",
412                                            "string"
413                                        ]
414                                    }
415                                },
416                                "required": [
417                                    "port"
418                                ],
419                                "type": "object"
420                            },
421                            "listen": {
422                                "label": "Attach by listening for incoming socket connection from debugpy",
423                                "properties": {
424                                    "host": {
425                                        "default": "127.0.0.1",
426                                        "description": "Hostname or IP address of the interface to listen on.",
427                                        "type": "string"
428                                    },
429                                    "port": {
430                                        "description": "Port to listen on.",
431                                        "type": [
432                                            "number",
433                                            "string"
434                                        ]
435                                    }
436                                },
437                                "required": [
438                                    "port"
439                                ],
440                                "type": "object"
441                            },
442                            "processId": {
443                                "anyOf": [
444                                    {
445                                        "default": "${command:pickProcess}",
446                                        "description": "Use process picker to select a process to attach, or Process ID as integer.",
447                                        "enum": [
448                                            "${command:pickProcess}"
449                                        ]
450                                    },
451                                    {
452                                        "description": "ID of the local process to attach to.",
453                                        "type": "integer"
454                                    }
455                                ]
456                            }
457                        }
458                    }
459                },
460                {
461                    "if": {
462                        "properties": {
463                            "request": {
464                                "enum": ["launch"]
465                            }
466                        }
467                    },
468                    "then": {
469                        "properties": {
470                            "args": {
471                                "default": [],
472                                "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.",
473                                "items": {
474                                    "type": "string"
475                                },
476                                "anyOf": [
477                                    {
478                                        "default": "${command:pickArgs}",
479                                        "enum": [
480                                            "${command:pickArgs}"
481                                        ]
482                                    },
483                                    {
484                                        "type": [
485                                            "array",
486                                            "string"
487                                        ]
488                                    }
489                                ]
490                            },
491                            "console": {
492                                "default": "integratedTerminal",
493                                "description": "Where to launch the debug target: internal console, integrated terminal, or external terminal.",
494                                "enum": [
495                                    "externalTerminal",
496                                    "integratedTerminal",
497                                    "internalConsole"
498                                ]
499                            },
500                            "cwd": {
501                                "default": "${ZED_WORKTREE_ROOT}",
502                                "description": "Absolute path to the working directory of the program being debugged. Default is the root directory of the file (leave empty).",
503                                "type": "string"
504                            },
505                            "autoStartBrowser": {
506                                "default": false,
507                                "description": "Open external browser to launch the application",
508                                "type": "boolean"
509                            },
510                            "env": {
511                                "additionalProperties": {
512                                    "type": "string"
513                                },
514                                "default": {},
515                                "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.",
516                                "type": "object"
517                            },
518                            "envFile": {
519                                "default": "${ZED_WORKTREE_ROOT}/.env",
520                                "description": "Absolute path to a file containing environment variable definitions.",
521                                "type": "string"
522                            },
523                            "gevent": {
524                                "default": false,
525                                "description": "Enable debugging of gevent monkey-patched code.",
526                                "type": "boolean"
527                            },
528                            "module": {
529                                "default": "",
530                                "description": "Name of the module to be debugged.",
531                                "type": "string"
532                            },
533                            "program": {
534                                "default": "${ZED_FILE}",
535                                "description": "Absolute path to the program.",
536                                "type": "string"
537                            },
538                            "purpose": {
539                                "default": [],
540                                "description": "Tells extension to use this configuration for test debugging, or when using debug-in-terminal command.",
541                                "items": {
542                                    "enum": [
543                                        "debug-test",
544                                        "debug-in-terminal"
545                                    ],
546                                    "enumDescriptions": [
547                                        "Use this configuration while debugging tests using test view or test debug commands.",
548                                        "Use this configuration while debugging a file using debug in terminal button in the editor."
549                                    ]
550                                },
551                                "type": "array"
552                            },
553                            "pyramid": {
554                                "default": false,
555                                "description": "Whether debugging Pyramid applications.",
556                                "type": "boolean"
557                            },
558                            "python": {
559                                "default": "${command:python.interpreterPath}",
560                                "description": "Absolute path to the Python interpreter executable; overrides workspace configuration if set.",
561                                "type": "string"
562                            },
563                            "pythonArgs": {
564                                "default": [],
565                                "description": "Command-line arguments passed to the Python interpreter. To pass arguments to the debug target, use \"args\".",
566                                "items": {
567                                    "type": "string"
568                                },
569                                "type": "array"
570                            },
571                            "stopOnEntry": {
572                                "default": false,
573                                "description": "Automatically stop after launch.",
574                                "type": "boolean"
575                            },
576                            "sudo": {
577                                "default": false,
578                                "description": "Running debug program under elevated permissions (on Unix).",
579                                "type": "boolean"
580                            },
581                            "guiEventLoop": {
582                                "default": "matplotlib",
583                                "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.",
584                                "type": "string"
585                            }
586                        }
587                    }
588                }
589            ]
590        })
591    }
592
593    async fn get_binary(
594        &self,
595        delegate: &Arc<dyn DapDelegate>,
596        config: &DebugTaskDefinition,
597        user_installed_path: Option<PathBuf>,
598        cx: &mut AsyncApp,
599    ) -> Result<DebugAdapterBinary> {
600        if let Some(local_path) = &user_installed_path {
601            log::debug!(
602                "Using user-installed debugpy adapter from: {}",
603                local_path.display()
604            );
605            return self
606                .get_installed_binary(delegate, &config, Some(local_path.clone()), None, false)
607                .await;
608        }
609
610        let toolchain = delegate
611            .toolchain_store()
612            .active_toolchain(
613                delegate.worktree_id(),
614                Arc::from("".as_ref()),
615                language::LanguageName::new(Self::LANGUAGE_NAME),
616                cx,
617            )
618            .await;
619
620        if let Some(toolchain) = &toolchain {
621            if let Some(path) = Path::new(&toolchain.path.to_string()).parent() {
622                let debugpy_path = path.join("debugpy");
623                if delegate.fs().is_file(&debugpy_path).await {
624                    log::debug!(
625                        "Found debugpy in toolchain environment: {}",
626                        debugpy_path.display()
627                    );
628                    return self
629                        .get_installed_binary(
630                            delegate,
631                            &config,
632                            None,
633                            Some(toolchain.clone()),
634                            true,
635                        )
636                        .await;
637                }
638            }
639        }
640
641        if self.checked.set(()).is_ok() {
642            delegate.output_to_console(format!("Checking latest version of {}...", self.name()));
643            if let Some(version) = self.fetch_latest_adapter_version(delegate).await.log_err() {
644                cx.background_spawn(Self::install_binary(self.name(), version, delegate.clone()))
645                    .await
646                    .context("Failed to install debugpy")?;
647            }
648        }
649
650        self.get_installed_binary(delegate, &config, None, toolchain, false)
651            .await
652    }
653}
654
655async fn fetch_latest_adapter_version_from_github(
656    github_repo: GithubRepo,
657    delegate: &dyn DapDelegate,
658) -> Result<AdapterVersion> {
659    let release = latest_github_release(
660        &format!("{}/{}", github_repo.repo_owner, github_repo.repo_name),
661        false,
662        false,
663        delegate.http_client(),
664    )
665    .await?;
666
667    Ok(AdapterVersion {
668        tag_name: release.tag_name,
669        url: release.tarball_url,
670    })
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use std::{net::Ipv4Addr, path::PathBuf};
677
678    #[gpui::test]
679    async fn test_debugpy_install_path_cases() {
680        let host = Ipv4Addr::new(127, 0, 0, 1);
681        let port = 5678;
682
683        // Case 1: User-defined debugpy path (highest precedence)
684        let user_path = PathBuf::from("/custom/path/to/debugpy");
685        let user_args =
686            PythonDebugAdapter::generate_debugpy_arguments(&host, port, Some(&user_path), false)
687                .await
688                .unwrap();
689
690        // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
691        let venv_args = PythonDebugAdapter::generate_debugpy_arguments(&host, port, None, true)
692            .await
693            .unwrap();
694
695        assert!(user_args[0].ends_with("src/debugpy/adapter"));
696        assert_eq!(user_args[1], "--host=127.0.0.1");
697        assert_eq!(user_args[2], "--port=5678");
698
699        assert_eq!(venv_args[0], "-m");
700        assert_eq!(venv_args[1], "debugpy.adapter");
701        assert_eq!(venv_args[2], "--host=127.0.0.1");
702        assert_eq!(venv_args[3], "--port=5678");
703
704        // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
705    }
706}