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