python.rs

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