python.rs

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