python.rs

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