python.rs

  1use crate::*;
  2use anyhow::Context as _;
  3use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
  4use gpui::{AsyncApp, SharedString};
  5use json_dotpath::DotPaths;
  6use language::{LanguageName, Toolchain};
  7use serde_json::Value;
  8use std::net::Ipv4Addr;
  9use std::{
 10    collections::HashMap,
 11    ffi::OsStr,
 12    path::{Path, PathBuf},
 13    sync::OnceLock,
 14};
 15use util::ResultExt;
 16
 17#[derive(Default)]
 18pub(crate) struct PythonDebugAdapter {
 19    checked: OnceLock<()>,
 20}
 21
 22impl PythonDebugAdapter {
 23    const ADAPTER_NAME: &'static str = "Debugpy";
 24    const ADAPTER_PACKAGE_NAME: &'static str = "debugpy";
 25    const ADAPTER_PATH: &'static str = "src/debugpy/adapter";
 26    const LANGUAGE_NAME: &'static str = "Python";
 27
 28    async fn generate_debugpy_arguments(
 29        &self,
 30        host: &Ipv4Addr,
 31        port: u16,
 32        user_installed_path: Option<&Path>,
 33        installed_in_venv: bool,
 34    ) -> Result<Vec<String>> {
 35        if let Some(user_installed_path) = user_installed_path {
 36            log::debug!(
 37                "Using user-installed debugpy adapter from: {}",
 38                user_installed_path.display()
 39            );
 40            Ok(vec![
 41                user_installed_path
 42                    .join(Self::ADAPTER_PATH)
 43                    .to_string_lossy()
 44                    .to_string(),
 45                format!("--host={}", host),
 46                format!("--port={}", port),
 47            ])
 48        } else if installed_in_venv {
 49            log::debug!("Using venv-installed debugpy");
 50            Ok(vec![
 51                "-m".to_string(),
 52                "debugpy.adapter".to_string(),
 53                format!("--host={}", host),
 54                format!("--port={}", port),
 55            ])
 56        } else {
 57            let adapter_path = paths::debug_adapters_dir().join(self.name().as_ref());
 58            let file_name_prefix = format!("{}_", Self::ADAPTER_NAME);
 59
 60            let debugpy_dir =
 61                util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| {
 62                    file_name.starts_with(&file_name_prefix)
 63                })
 64                .await
 65                .context("Debugpy directory not found")?;
 66
 67            log::debug!(
 68                "Using GitHub-downloaded debugpy adapter from: {}",
 69                debugpy_dir.display()
 70            );
 71            Ok(vec![
 72                debugpy_dir
 73                    .join(Self::ADAPTER_PATH)
 74                    .to_string_lossy()
 75                    .to_string(),
 76                format!("--host={}", host),
 77                format!("--port={}", port),
 78            ])
 79        }
 80    }
 81
 82    fn request_args(
 83        &self,
 84        task_definition: &DebugTaskDefinition,
 85    ) -> Result<StartDebuggingRequestArguments> {
 86        let request = self.request_kind(&task_definition.config)?;
 87
 88        let mut configuration = task_definition.config.clone();
 89        if let Ok(console) = configuration.dot_get_mut("console") {
 90            // Use built-in Zed terminal if user did not explicitly provide a setting for console.
 91            if console.is_null() {
 92                *console = Value::String("integratedTerminal".into());
 93            }
 94        }
 95
 96        Ok(StartDebuggingRequestArguments {
 97            configuration,
 98            request,
 99        })
100    }
101    async fn fetch_latest_adapter_version(
102        &self,
103        delegate: &Arc<dyn DapDelegate>,
104    ) -> Result<AdapterVersion> {
105        let github_repo = GithubRepo {
106            repo_name: Self::ADAPTER_PACKAGE_NAME.into(),
107            repo_owner: "microsoft".into(),
108        };
109
110        adapters::fetch_latest_adapter_version_from_github(github_repo, delegate.as_ref()).await
111    }
112
113    async fn install_binary(
114        &self,
115        version: AdapterVersion,
116        delegate: &Arc<dyn DapDelegate>,
117    ) -> Result<()> {
118        let version_path = adapters::download_adapter_from_github(
119            self.name(),
120            version,
121            adapters::DownloadedFileType::Zip,
122            delegate.as_ref(),
123        )
124        .await?;
125
126        // only needed when you install the latest version for the first time
127        if let Some(debugpy_dir) =
128            util::fs::find_file_name_in_dir(version_path.as_path(), |file_name| {
129                file_name.starts_with("microsoft-debugpy-")
130            })
131            .await
132        {
133            // TODO Debugger: Rename folder instead of moving all files to another folder
134            // We're doing unnecessary IO work right now
135            util::fs::move_folder_files_to_folder(debugpy_dir.as_path(), version_path.as_path())
136                .await?;
137        }
138
139        Ok(())
140    }
141
142    async fn get_installed_binary(
143        &self,
144        delegate: &Arc<dyn DapDelegate>,
145        config: &DebugTaskDefinition,
146        user_installed_path: Option<PathBuf>,
147        toolchain: Option<Toolchain>,
148        installed_in_venv: bool,
149    ) -> Result<DebugAdapterBinary> {
150        const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
151        let tcp_connection = config.tcp_connection.clone().unwrap_or_default();
152        let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
153
154        let python_path = if let Some(toolchain) = toolchain {
155            Some(toolchain.path.to_string())
156        } else {
157            let mut name = None;
158
159            for cmd in BINARY_NAMES {
160                name = delegate
161                    .which(OsStr::new(cmd))
162                    .await
163                    .map(|path| path.to_string_lossy().to_string());
164                if name.is_some() {
165                    break;
166                }
167            }
168            name
169        };
170
171        let python_command = python_path.context("failed to find binary path for Python")?;
172        log::debug!("Using Python executable: {}", python_command);
173
174        let arguments = self
175            .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: 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        DebugAdapterName(Self::ADAPTER_NAME.into())
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                self.install_binary(version, delegate).await?;
639            }
640        }
641
642        self.get_installed_binary(delegate, &config, None, toolchain, false)
643            .await
644    }
645}
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650    use std::{net::Ipv4Addr, path::PathBuf};
651
652    #[gpui::test]
653    async fn test_debugpy_install_path_cases() {
654        let adapter = PythonDebugAdapter::default();
655        let host = Ipv4Addr::new(127, 0, 0, 1);
656        let port = 5678;
657
658        // Case 1: User-defined debugpy path (highest precedence)
659        let user_path = PathBuf::from("/custom/path/to/debugpy");
660        let user_args = adapter
661            .generate_debugpy_arguments(&host, port, Some(&user_path), false)
662            .await
663            .unwrap();
664
665        // Case 2: Venv-installed debugpy (uses -m debugpy.adapter)
666        let venv_args = adapter
667            .generate_debugpy_arguments(&host, port, None, true)
668            .await
669            .unwrap();
670
671        assert!(user_args[0].ends_with("src/debugpy/adapter"));
672        assert_eq!(user_args[1], "--host=127.0.0.1");
673        assert_eq!(user_args[2], "--port=5678");
674
675        assert_eq!(venv_args[0], "-m");
676        assert_eq!(venv_args[1], "debugpy.adapter");
677        assert_eq!(venv_args[2], "--host=127.0.0.1");
678        assert_eq!(venv_args[3], "--port=5678");
679
680        // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
681    }
682
683    #[test]
684    fn test_adapter_path_constant() {
685        assert_eq!(PythonDebugAdapter::ADAPTER_PATH, "src/debugpy/adapter");
686    }
687}