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