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