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