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