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