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