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