python.rs

   1use crate::*;
   2use anyhow::{Context as _, bail};
   3use collections::HashMap;
   4use dap::{DebugRequest, StartDebuggingRequestArguments, adapters::DebugTaskDefinition};
   5use fs::RemoveOptions;
   6use futures::{StreamExt, TryStreamExt};
   7use gpui::http_client::AsyncBody;
   8use gpui::{AsyncApp, SharedString};
   9use json_dotpath::DotPaths;
  10use language::{LanguageName, Toolchain};
  11use paths::debug_adapters_dir;
  12use serde_json::Value;
  13use smol::fs::File;
  14use smol::io::AsyncReadExt;
  15use smol::lock::OnceCell;
  16use std::ffi::OsString;
  17use std::net::Ipv4Addr;
  18use std::str::FromStr;
  19use std::{
  20    ffi::OsStr,
  21    path::{Path, PathBuf},
  22};
  23use util::command::new_smol_command;
  24use util::{ResultExt, paths::PathStyle, rel_path::RelPath};
  25
  26enum DebugpyLaunchMode<'a> {
  27    Normal,
  28    AttachWithConnect { host: Option<&'a str> },
  29}
  30
  31#[derive(Default)]
  32pub(crate) struct PythonDebugAdapter {
  33    base_venv_path: OnceCell<Result<Arc<Path>, String>>,
  34    debugpy_whl_base_path: OnceCell<Result<Arc<Path>, String>>,
  35}
  36
  37impl PythonDebugAdapter {
  38    const ADAPTER_NAME: &'static str = "Debugpy";
  39    const DEBUG_ADAPTER_NAME: DebugAdapterName =
  40        DebugAdapterName(SharedString::new_static(Self::ADAPTER_NAME));
  41
  42    const LANGUAGE_NAME: &'static str = "Python";
  43
  44    async fn generate_debugpy_arguments<'a>(
  45        host: &'a Ipv4Addr,
  46        port: u16,
  47        launch_mode: DebugpyLaunchMode<'a>,
  48        user_installed_path: Option<&'a Path>,
  49        user_args: Option<Vec<String>>,
  50    ) -> Result<Vec<String>> {
  51        let mut args = if let Some(user_installed_path) = user_installed_path {
  52            log::debug!(
  53                "Using user-installed debugpy adapter from: {}",
  54                user_installed_path.display()
  55            );
  56            vec![user_installed_path.to_string_lossy().into_owned()]
  57        } else {
  58            let adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
  59            let path = adapter_path
  60                .join("debugpy")
  61                .join("adapter")
  62                .to_string_lossy()
  63                .into_owned();
  64            log::debug!("Using pip debugpy adapter from: {path}");
  65            vec![path]
  66        };
  67
  68        args.extend(if let Some(args) = user_args {
  69            args
  70        } else {
  71            match launch_mode {
  72                DebugpyLaunchMode::Normal => {
  73                    vec![format!("--host={}", host), format!("--port={}", port)]
  74                }
  75                DebugpyLaunchMode::AttachWithConnect { host } => {
  76                    let mut args = vec!["connect".to_string()];
  77
  78                    if let Some(host) = host {
  79                        args.push(format!("{host}:"));
  80                    }
  81                    args.push(format!("{port}"));
  82                    args
  83                }
  84            }
  85        });
  86        Ok(args)
  87    }
  88
  89    async fn request_args(
  90        &self,
  91        delegate: &Arc<dyn DapDelegate>,
  92        task_definition: &DebugTaskDefinition,
  93    ) -> Result<StartDebuggingRequestArguments> {
  94        let request = self.request_kind(&task_definition.config).await?;
  95
  96        let mut configuration = task_definition.config.clone();
  97        if let Ok(console) = configuration.dot_get_mut("console") {
  98            // Use built-in Zed terminal if user did not explicitly provide a setting for console.
  99            if console.is_null() {
 100                *console = Value::String("integratedTerminal".into());
 101            }
 102        }
 103
 104        if let Some(obj) = configuration.as_object_mut() {
 105            obj.entry("cwd")
 106                .or_insert(delegate.worktree_root_path().to_string_lossy().into());
 107        }
 108
 109        Ok(StartDebuggingRequestArguments {
 110            configuration,
 111            request,
 112        })
 113    }
 114
 115    async fn fetch_wheel(
 116        &self,
 117        toolchain: Option<Toolchain>,
 118        delegate: &Arc<dyn DapDelegate>,
 119    ) -> Result<Arc<Path>> {
 120        let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME).join("wheels");
 121        std::fs::create_dir_all(&download_dir)?;
 122        let venv_python = self.base_venv_path(toolchain, delegate).await?;
 123
 124        let installation_succeeded = util::command::new_smol_command(venv_python.as_ref())
 125            .args([
 126                "-m",
 127                "pip",
 128                "download",
 129                "debugpy",
 130                "--only-binary=:all:",
 131                "-d",
 132                download_dir.to_string_lossy().as_ref(),
 133            ])
 134            .output()
 135            .await
 136            .context("spawn system python")?
 137            .status
 138            .success();
 139        if !installation_succeeded {
 140            bail!("debugpy installation failed (could not fetch Debugpy's wheel)");
 141        }
 142
 143        let wheel_path = std::fs::read_dir(&download_dir)?
 144            .find_map(|entry| {
 145                entry.ok().filter(|e| {
 146                    e.file_type().is_ok_and(|typ| typ.is_file())
 147                        && Path::new(&e.file_name()).extension() == Some("whl".as_ref())
 148                })
 149            })
 150            .with_context(|| format!("Did not find a .whl in {download_dir:?}"))?;
 151
 152        util::archive::extract_zip(
 153            &debug_adapters_dir().join(Self::ADAPTER_NAME),
 154            File::open(&wheel_path.path()).await?,
 155        )
 156        .await?;
 157
 158        Ok(Arc::from(wheel_path.path()))
 159    }
 160
 161    async fn maybe_fetch_new_wheel(
 162        &self,
 163        toolchain: Option<Toolchain>,
 164        delegate: &Arc<dyn DapDelegate>,
 165    ) -> Result<()> {
 166        let latest_release = delegate
 167            .http_client()
 168            .get(
 169                "https://pypi.org/pypi/debugpy/json",
 170                AsyncBody::empty(),
 171                false,
 172            )
 173            .await
 174            .log_err();
 175        let response = latest_release
 176            .filter(|response| response.status().is_success())
 177            .context("getting latest release")?;
 178
 179        let download_dir = debug_adapters_dir().join(Self::ADAPTER_NAME);
 180        std::fs::create_dir_all(&download_dir)?;
 181
 182        let mut output = String::new();
 183        response.into_body().read_to_string(&mut output).await?;
 184        let as_json = serde_json::Value::from_str(&output)?;
 185        let latest_version = as_json
 186            .get("info")
 187            .and_then(|info| {
 188                info.get("version")
 189                    .and_then(|version| version.as_str())
 190                    .map(ToOwned::to_owned)
 191            })
 192            .context("parsing latest release information")?;
 193        let dist_info_dirname: OsString = format!("debugpy-{latest_version}.dist-info").into();
 194        let is_up_to_date = delegate
 195            .fs()
 196            .read_dir(&debug_adapters_dir().join(Self::ADAPTER_NAME))
 197            .await?
 198            .into_stream()
 199            .any(async |entry| {
 200                entry.is_ok_and(|e| e.file_name().is_some_and(|name| name == dist_info_dirname))
 201            })
 202            .await;
 203
 204        if !is_up_to_date {
 205            delegate
 206                .fs()
 207                .remove_dir(
 208                    &debug_adapters_dir().join(Self::ADAPTER_NAME),
 209                    RemoveOptions {
 210                        recursive: true,
 211                        ignore_if_not_exists: true,
 212                    },
 213                )
 214                .await?;
 215            self.fetch_wheel(toolchain, delegate).await?;
 216        }
 217        anyhow::Ok(())
 218    }
 219
 220    async fn fetch_debugpy_whl(
 221        &self,
 222        toolchain: Option<Toolchain>,
 223        delegate: &Arc<dyn DapDelegate>,
 224    ) -> Result<Arc<Path>, String> {
 225        self.debugpy_whl_base_path
 226            .get_or_init(|| async move {
 227                self.maybe_fetch_new_wheel(toolchain, delegate)
 228                    .await
 229                    .map_err(|e| format!("{e}"))?;
 230                Ok(Arc::from(
 231                    debug_adapters_dir()
 232                        .join(Self::ADAPTER_NAME)
 233                        .join("debugpy")
 234                        .join("adapter")
 235                        .as_ref(),
 236                ))
 237            })
 238            .await
 239            .clone()
 240    }
 241
 242    async fn base_venv_path(
 243        &self,
 244        toolchain: Option<Toolchain>,
 245        delegate: &Arc<dyn DapDelegate>,
 246    ) -> Result<Arc<Path>> {
 247        let result = self.base_venv_path
 248            .get_or_init(|| async {
 249                let base_python = if let Some(toolchain) = toolchain {
 250                    toolchain.path.to_string()
 251                } else {
 252                    Self::system_python_name(delegate).await.ok_or_else(|| {
 253                        let mut message = "Could not find a Python installation".to_owned();
 254                        if cfg!(windows){
 255                            message.push_str(". Install Python from the Microsoft Store, or manually from https://www.python.org/downloads/windows.")
 256                        }
 257                        message
 258                    })?
 259                };
 260
 261                let debug_adapter_path = paths::debug_adapters_dir().join(Self::DEBUG_ADAPTER_NAME.as_ref());
 262                let output = util::command::new_smol_command(&base_python)
 263                    .args(["-m", "venv", "zed_base_venv"])
 264                    .current_dir(
 265                        &debug_adapter_path,
 266                    )
 267                    .spawn()
 268                    .map_err(|e| format!("{e:#?}"))?
 269                    .output()
 270                    .await
 271                    .map_err(|e| format!("{e:#?}"))?;
 272
 273                if !output.status.success() {
 274                    let stderr = String::from_utf8_lossy(&output.stderr);
 275                    let stdout = String::from_utf8_lossy(&output.stdout);
 276                    let debug_adapter_path = debug_adapter_path.display();
 277                    return Err(format!("Failed to create base virtual environment with {base_python} in:\n{debug_adapter_path}\nstderr:\n{stderr}\nstdout:\n{stdout}\n"));
 278                }
 279
 280                const PYTHON_PATH: &str = if cfg!(target_os = "windows") {
 281                    "Scripts/python.exe"
 282                } else {
 283                    "bin/python3"
 284                };
 285                Ok(Arc::from(
 286                    paths::debug_adapters_dir()
 287                        .join(Self::DEBUG_ADAPTER_NAME.as_ref())
 288                        .join("zed_base_venv")
 289                        .join(PYTHON_PATH)
 290                        .as_ref(),
 291                ))
 292            })
 293            .await
 294            .clone();
 295        match result {
 296            Ok(path) => Ok(path),
 297            Err(e) => Err(anyhow::anyhow!("{e}")),
 298        }
 299    }
 300    async fn system_python_name(delegate: &Arc<dyn DapDelegate>) -> Option<String> {
 301        const BINARY_NAMES: [&str; 3] = ["python3", "python", "py"];
 302        let mut name = None;
 303
 304        for cmd in BINARY_NAMES {
 305            let Some(path) = delegate.which(OsStr::new(cmd)).await else {
 306                continue;
 307            };
 308            // Try to detect situations where `python3` exists but is not a real Python interpreter.
 309            // Notably, on fresh Windows installs, `python3` is a shim that opens the Microsoft Store app
 310            // when run with no arguments, and just fails otherwise.
 311            let Some(output) = new_smol_command(&path)
 312                .args(["-c", "print(1 + 2)"])
 313                .output()
 314                .await
 315                .ok()
 316            else {
 317                continue;
 318            };
 319            if output.stdout.trim_ascii() != b"3" {
 320                continue;
 321            }
 322            name = Some(path.to_string_lossy().into_owned());
 323            break;
 324        }
 325        name
 326    }
 327
 328    async fn get_installed_binary(
 329        &self,
 330        delegate: &Arc<dyn DapDelegate>,
 331        config: &DebugTaskDefinition,
 332        user_installed_path: Option<PathBuf>,
 333        user_args: Option<Vec<String>>,
 334        user_env: Option<HashMap<String, String>>,
 335        python_from_toolchain: Option<String>,
 336    ) -> Result<DebugAdapterBinary> {
 337        let mut tcp_connection = config.tcp_connection.clone().unwrap_or_default();
 338
 339        let (config_port, config_host) = config
 340            .config
 341            .get("connect")
 342            .map(|value| {
 343                (
 344                    value
 345                        .get("port")
 346                        .and_then(|val| val.as_u64().map(|p| p as u16)),
 347                    value.get("host").and_then(|val| val.as_str()),
 348                )
 349            })
 350            .unwrap_or_else(|| {
 351                (
 352                    config
 353                        .config
 354                        .get("port")
 355                        .and_then(|port| port.as_u64().map(|p| p as u16)),
 356                    config.config.get("host").and_then(|host| host.as_str()),
 357                )
 358            });
 359
 360        let is_attach_with_connect = if config
 361            .config
 362            .get("request")
 363            .is_some_and(|val| val.as_str().is_some_and(|request| request == "attach"))
 364        {
 365            if tcp_connection.host.is_some() && config_host.is_some() {
 366                bail!("Cannot have two different hosts in debug configuration")
 367            } else if tcp_connection.port.is_some() && config_port.is_some() {
 368                bail!("Cannot have two different ports in debug configuration")
 369            }
 370
 371            if let Some(hostname) = config_host {
 372                tcp_connection.host = Some(hostname.parse().context("hostname must be IPv4")?);
 373            }
 374            tcp_connection.port = config_port;
 375            DebugpyLaunchMode::AttachWithConnect { host: config_host }
 376        } else {
 377            DebugpyLaunchMode::Normal
 378        };
 379
 380        let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?;
 381
 382        let python_path = if let Some(toolchain) = python_from_toolchain {
 383            Some(toolchain)
 384        } else {
 385            Self::system_python_name(delegate).await
 386        };
 387
 388        let python_command = python_path.context("failed to find binary path for Python")?;
 389        log::debug!("Using Python executable: {}", python_command);
 390
 391        let arguments = Self::generate_debugpy_arguments(
 392            &host,
 393            port,
 394            is_attach_with_connect,
 395            user_installed_path.as_deref(),
 396            user_args,
 397        )
 398        .await?;
 399
 400        log::debug!(
 401            "Starting debugpy adapter with command: {} {}",
 402            python_command,
 403            arguments.join(" ")
 404        );
 405
 406        Ok(DebugAdapterBinary {
 407            command: Some(python_command),
 408            arguments,
 409            connection: Some(adapters::TcpArguments {
 410                host,
 411                port,
 412                timeout,
 413            }),
 414            cwd: Some(delegate.worktree_root_path().to_path_buf()),
 415            envs: user_env.unwrap_or_default(),
 416            request_args: self.request_args(delegate, config).await?,
 417        })
 418    }
 419}
 420
 421#[async_trait(?Send)]
 422impl DebugAdapter for PythonDebugAdapter {
 423    fn name(&self) -> DebugAdapterName {
 424        Self::DEBUG_ADAPTER_NAME
 425    }
 426
 427    fn adapter_language_name(&self) -> Option<LanguageName> {
 428        Some(SharedString::new_static("Python").into())
 429    }
 430
 431    async fn config_from_zed_format(&self, zed_scenario: ZedDebugConfig) -> Result<DebugScenario> {
 432        let mut args = json!({
 433            "request": match zed_scenario.request {
 434                DebugRequest::Launch(_) => "launch",
 435                DebugRequest::Attach(_) => "attach",
 436            },
 437            "subProcess": true,
 438            "redirectOutput": true,
 439        });
 440
 441        let map = args.as_object_mut().unwrap();
 442        match &zed_scenario.request {
 443            DebugRequest::Attach(attach) => {
 444                map.insert("processId".into(), attach.process_id.into());
 445            }
 446            DebugRequest::Launch(launch) => {
 447                map.insert("program".into(), launch.program.clone().into());
 448                map.insert("args".into(), launch.args.clone().into());
 449                if !launch.env.is_empty() {
 450                    map.insert("env".into(), launch.env_json());
 451                }
 452
 453                if let Some(stop_on_entry) = zed_scenario.stop_on_entry {
 454                    map.insert("stopOnEntry".into(), stop_on_entry.into());
 455                }
 456                if let Some(cwd) = launch.cwd.as_ref() {
 457                    map.insert("cwd".into(), cwd.to_string_lossy().into_owned().into());
 458                }
 459            }
 460        }
 461
 462        Ok(DebugScenario {
 463            adapter: zed_scenario.adapter,
 464            label: zed_scenario.label,
 465            config: args,
 466            build: None,
 467            tcp_connection: None,
 468        })
 469    }
 470
 471    fn dap_schema(&self) -> serde_json::Value {
 472        json!({
 473            "properties": {
 474                "request": {
 475                    "type": "string",
 476                    "enum": ["attach", "launch"],
 477                    "description": "Debug adapter request type"
 478                },
 479                "autoReload": {
 480                    "default": {},
 481                    "description": "Configures automatic reload of code on edit.",
 482                    "properties": {
 483                        "enable": {
 484                            "default": false,
 485                            "description": "Automatically reload code on edit.",
 486                            "type": "boolean"
 487                        },
 488                        "exclude": {
 489                            "default": [
 490                                "**/.git/**",
 491                                "**/.metadata/**",
 492                                "**/__pycache__/**",
 493                                "**/node_modules/**",
 494                                "**/site-packages/**"
 495                            ],
 496                            "description": "Glob patterns of paths to exclude from auto reload.",
 497                            "items": {
 498                                "type": "string"
 499                            },
 500                            "type": "array"
 501                        },
 502                        "include": {
 503                            "default": [
 504                                "**/*.py",
 505                                "**/*.pyw"
 506                            ],
 507                            "description": "Glob patterns of paths to include in auto reload.",
 508                            "items": {
 509                                "type": "string"
 510                            },
 511                            "type": "array"
 512                        }
 513                    },
 514                    "type": "object"
 515                },
 516                "debugAdapterPath": {
 517                    "description": "Path (fully qualified) to the python debug adapter executable.",
 518                    "type": "string"
 519                },
 520                "django": {
 521                    "default": false,
 522                    "description": "Django debugging.",
 523                    "type": "boolean"
 524                },
 525                "jinja": {
 526                    "default": null,
 527                    "description": "Jinja template debugging (e.g. Flask).",
 528                    "enum": [
 529                        false,
 530                        null,
 531                        true
 532                    ]
 533                },
 534                "justMyCode": {
 535                    "default": true,
 536                    "description": "If true, show and debug only user-written code. If false, show and debug all code, including library calls.",
 537                    "type": "boolean"
 538                },
 539                "logToFile": {
 540                    "default": false,
 541                    "description": "Enable logging of debugger events to a log file. This file can be found in the debugpy extension install folder.",
 542                    "type": "boolean"
 543                },
 544                "pathMappings": {
 545                    "default": [],
 546                    "items": {
 547                        "label": "Path mapping",
 548                        "properties": {
 549                            "localRoot": {
 550                                "default": "${ZED_WORKTREE_ROOT}",
 551                                "label": "Local source root.",
 552                                "type": "string"
 553                            },
 554                            "remoteRoot": {
 555                                "default": "",
 556                                "label": "Remote source root.",
 557                                "type": "string"
 558                            }
 559                        },
 560                        "required": [
 561                            "localRoot",
 562                            "remoteRoot"
 563                        ],
 564                        "type": "object"
 565                    },
 566                    "label": "Path mappings.",
 567                    "type": "array"
 568                },
 569                "redirectOutput": {
 570                    "default": true,
 571                    "description": "Redirect output.",
 572                    "type": "boolean"
 573                },
 574                "showReturnValue": {
 575                    "default": true,
 576                    "description": "Show return value of functions when stepping.",
 577                    "type": "boolean"
 578                },
 579                "subProcess": {
 580                    "default": false,
 581                    "description": "Whether to enable Sub Process debugging",
 582                    "type": "boolean"
 583                },
 584                "consoleName": {
 585                    "default": "Python Debug Console",
 586                    "description": "Display name of the debug console or terminal",
 587                    "type": "string"
 588                },
 589                "clientOS": {
 590                    "default": null,
 591                    "description": "OS that VS code is using.",
 592                    "enum": [
 593                        "windows",
 594                        null,
 595                        "unix"
 596                    ]
 597                }
 598            },
 599            "required": ["request"],
 600            "allOf": [
 601                {
 602                    "if": {
 603                        "properties": {
 604                            "request": {
 605                                "enum": ["attach"]
 606                            }
 607                        }
 608                    },
 609                    "then": {
 610                        "properties": {
 611                            "connect": {
 612                                "label": "Attach by connecting to debugpy over a socket.",
 613                                "properties": {
 614                                    "host": {
 615                                        "default": "127.0.0.1",
 616                                        "description": "Hostname or IP address to connect to.",
 617                                        "type": "string"
 618                                    },
 619                                    "port": {
 620                                        "description": "Port to connect to.",
 621                                        "type": [
 622                                            "number",
 623                                            "string"
 624                                        ]
 625                                    }
 626                                },
 627                                "required": [
 628                                    "port"
 629                                ],
 630                                "type": "object"
 631                            },
 632                            "listen": {
 633                                "label": "Attach by listening for incoming socket connection from debugpy",
 634                                "properties": {
 635                                    "host": {
 636                                        "default": "127.0.0.1",
 637                                        "description": "Hostname or IP address of the interface to listen on.",
 638                                        "type": "string"
 639                                    },
 640                                    "port": {
 641                                        "description": "Port to listen on.",
 642                                        "type": [
 643                                            "number",
 644                                            "string"
 645                                        ]
 646                                    }
 647                                },
 648                                "required": [
 649                                    "port"
 650                                ],
 651                                "type": "object"
 652                            },
 653                            "processId": {
 654                                "anyOf": [
 655                                    {
 656                                        "default": "${command:pickProcess}",
 657                                        "description": "Use process picker to select a process to attach, or Process ID as integer.",
 658                                        "enum": [
 659                                            "${command:pickProcess}"
 660                                        ]
 661                                    },
 662                                    {
 663                                        "description": "ID of the local process to attach to.",
 664                                        "type": "integer"
 665                                    }
 666                                ]
 667                            }
 668                        }
 669                    }
 670                },
 671                {
 672                    "if": {
 673                        "properties": {
 674                            "request": {
 675                                "enum": ["launch"]
 676                            }
 677                        }
 678                    },
 679                    "then": {
 680                        "properties": {
 681                            "args": {
 682                                "default": [],
 683                                "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.",
 684                                "items": {
 685                                    "type": "string"
 686                                },
 687                                "anyOf": [
 688                                    {
 689                                        "default": "${command:pickArgs}",
 690                                        "enum": [
 691                                            "${command:pickArgs}"
 692                                        ]
 693                                    },
 694                                    {
 695                                        "type": [
 696                                            "array",
 697                                            "string"
 698                                        ]
 699                                    }
 700                                ]
 701                            },
 702                            "console": {
 703                                "default": "integratedTerminal",
 704                                "description": "Where to launch the debug target: internal console, integrated terminal, or external terminal.",
 705                                "enum": [
 706                                    "externalTerminal",
 707                                    "integratedTerminal",
 708                                    "internalConsole"
 709                                ]
 710                            },
 711                            "cwd": {
 712                                "default": "${ZED_WORKTREE_ROOT}",
 713                                "description": "Absolute path to the working directory of the program being debugged. Default is the root directory of the file (leave empty).",
 714                                "type": "string"
 715                            },
 716                            "autoStartBrowser": {
 717                                "default": false,
 718                                "description": "Open external browser to launch the application",
 719                                "type": "boolean"
 720                            },
 721                            "env": {
 722                                "additionalProperties": {
 723                                    "type": "string"
 724                                },
 725                                "default": {},
 726                                "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.",
 727                                "type": "object"
 728                            },
 729                            "envFile": {
 730                                "default": "${ZED_WORKTREE_ROOT}/.env",
 731                                "description": "Absolute path to a file containing environment variable definitions.",
 732                                "type": "string"
 733                            },
 734                            "gevent": {
 735                                "default": false,
 736                                "description": "Enable debugging of gevent monkey-patched code.",
 737                                "type": "boolean"
 738                            },
 739                            "module": {
 740                                "default": "",
 741                                "description": "Name of the module to be debugged.",
 742                                "type": "string"
 743                            },
 744                            "program": {
 745                                "default": "${ZED_FILE}",
 746                                "description": "Absolute path to the program.",
 747                                "type": "string"
 748                            },
 749                            "purpose": {
 750                                "default": [],
 751                                "description": "Tells extension to use this configuration for test debugging, or when using debug-in-terminal command.",
 752                                "items": {
 753                                    "enum": [
 754                                        "debug-test",
 755                                        "debug-in-terminal"
 756                                    ],
 757                                    "enumDescriptions": [
 758                                        "Use this configuration while debugging tests using test view or test debug commands.",
 759                                        "Use this configuration while debugging a file using debug in terminal button in the editor."
 760                                    ]
 761                                },
 762                                "type": "array"
 763                            },
 764                            "pyramid": {
 765                                "default": false,
 766                                "description": "Whether debugging Pyramid applications.",
 767                                "type": "boolean"
 768                            },
 769                            "python": {
 770                                "default": "${command:python.interpreterPath}",
 771                                "description": "Absolute path to the Python interpreter executable; overrides workspace configuration if set.",
 772                                "type": "string"
 773                            },
 774                            "pythonArgs": {
 775                                "default": [],
 776                                "description": "Command-line arguments passed to the Python interpreter. To pass arguments to the debug target, use \"args\".",
 777                                "items": {
 778                                    "type": "string"
 779                                },
 780                                "type": "array"
 781                            },
 782                            "stopOnEntry": {
 783                                "default": false,
 784                                "description": "Automatically stop after launch.",
 785                                "type": "boolean"
 786                            },
 787                            "sudo": {
 788                                "default": false,
 789                                "description": "Running debug program under elevated permissions (on Unix).",
 790                                "type": "boolean"
 791                            },
 792                            "guiEventLoop": {
 793                                "default": "matplotlib",
 794                                "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.",
 795                                "type": "string"
 796                            }
 797                        }
 798                    }
 799                }
 800            ]
 801        })
 802    }
 803
 804    async fn get_binary(
 805        &self,
 806        delegate: &Arc<dyn DapDelegate>,
 807        config: &DebugTaskDefinition,
 808        user_installed_path: Option<PathBuf>,
 809        user_args: Option<Vec<String>>,
 810        user_env: Option<HashMap<String, String>>,
 811        cx: &mut AsyncApp,
 812    ) -> Result<DebugAdapterBinary> {
 813        if let Some(local_path) = &user_installed_path {
 814            log::debug!(
 815                "Using user-installed debugpy adapter from: {}",
 816                local_path.display()
 817            );
 818            return self
 819                .get_installed_binary(
 820                    delegate,
 821                    config,
 822                    Some(local_path.clone()),
 823                    user_args,
 824                    user_env,
 825                    None,
 826                )
 827                .await;
 828        }
 829
 830        let base_paths = ["cwd", "program", "module"]
 831            .into_iter()
 832            .filter_map(|key| {
 833                config.config.get(key).and_then(|cwd| {
 834                    RelPath::new(
 835                        cwd.as_str()
 836                            .map(Path::new)?
 837                            .strip_prefix(delegate.worktree_root_path())
 838                            .ok()?,
 839                        PathStyle::local(),
 840                    )
 841                    .ok()
 842                })
 843            })
 844            .chain(
 845                // While Debugpy's wiki saids absolute paths are required, but it actually supports relative paths when cwd is passed in.
 846                // (Which should always be the case because Zed defaults to the cwd worktree root)
 847                // So we want to check that these relative paths find toolchains as well. Otherwise, they won't be checked
 848                // because the strip prefix in the iteration above will return an error
 849                config
 850                    .config
 851                    .get("cwd")
 852                    .map(|_| {
 853                        ["program", "module"].into_iter().filter_map(|key| {
 854                            config.config.get(key).and_then(|value| {
 855                                let path = Path::new(value.as_str()?);
 856                                RelPath::new(path, PathStyle::local()).ok()
 857                            })
 858                        })
 859                    })
 860                    .into_iter()
 861                    .flatten(),
 862            )
 863            .chain([RelPath::empty().into()]);
 864
 865        let mut toolchain = None;
 866
 867        for base_path in base_paths {
 868            if let Some(found_toolchain) = delegate
 869                .toolchain_store()
 870                .active_toolchain(
 871                    delegate.worktree_id(),
 872                    base_path.into_arc(),
 873                    language::LanguageName::new(Self::LANGUAGE_NAME),
 874                    cx,
 875                )
 876                .await
 877            {
 878                toolchain = Some(found_toolchain);
 879                break;
 880            }
 881        }
 882
 883        self.fetch_debugpy_whl(toolchain.clone(), delegate)
 884            .await
 885            .map_err(|e| anyhow::anyhow!("{e}"))?;
 886        if let Some(toolchain) = &toolchain {
 887            return self
 888                .get_installed_binary(
 889                    delegate,
 890                    config,
 891                    None,
 892                    user_args,
 893                    user_env,
 894                    Some(toolchain.path.to_string()),
 895                )
 896                .await;
 897        }
 898
 899        self.get_installed_binary(delegate, config, None, user_args, user_env, None)
 900            .await
 901    }
 902
 903    fn label_for_child_session(&self, args: &StartDebuggingRequestArguments) -> Option<String> {
 904        let label = args
 905            .configuration
 906            .get("name")?
 907            .as_str()
 908            .filter(|label| !label.is_empty())?;
 909        Some(label.to_owned())
 910    }
 911}
 912
 913#[cfg(test)]
 914mod tests {
 915    use util::path;
 916
 917    use super::*;
 918    use task::TcpArgumentsTemplate;
 919
 920    #[gpui::test]
 921    async fn test_tcp_connection_conflict_with_connect_args() {
 922        let adapter = PythonDebugAdapter {
 923            base_venv_path: OnceCell::new(),
 924            debugpy_whl_base_path: OnceCell::new(),
 925        };
 926
 927        let config_with_port_conflict = json!({
 928            "request": "attach",
 929            "connect": {
 930                "port": 5679
 931            }
 932        });
 933
 934        let tcp_connection = TcpArgumentsTemplate {
 935            host: None,
 936            port: Some(5678),
 937            timeout: None,
 938        };
 939
 940        let task_def = DebugTaskDefinition {
 941            label: "test".into(),
 942            adapter: PythonDebugAdapter::ADAPTER_NAME.into(),
 943            config: config_with_port_conflict,
 944            tcp_connection: Some(tcp_connection.clone()),
 945        };
 946
 947        let result = adapter
 948            .get_installed_binary(
 949                &test_mocks::MockDelegate::new(),
 950                &task_def,
 951                None,
 952                None,
 953                None,
 954                Some("python3".to_string()),
 955            )
 956            .await;
 957
 958        assert!(result.is_err());
 959        assert!(
 960            result
 961                .unwrap_err()
 962                .to_string()
 963                .contains("Cannot have two different ports")
 964        );
 965
 966        let host = Ipv4Addr::new(127, 0, 0, 1);
 967        let config_with_host_conflict = json!({
 968            "request": "attach",
 969            "connect": {
 970                "host": "192.168.1.1",
 971                "port": 5678
 972            }
 973        });
 974
 975        let tcp_connection_with_host = TcpArgumentsTemplate {
 976            host: Some(host),
 977            port: None,
 978            timeout: None,
 979        };
 980
 981        let task_def_host = DebugTaskDefinition {
 982            label: "test".into(),
 983            adapter: PythonDebugAdapter::ADAPTER_NAME.into(),
 984            config: config_with_host_conflict,
 985            tcp_connection: Some(tcp_connection_with_host),
 986        };
 987
 988        let result_host = adapter
 989            .get_installed_binary(
 990                &test_mocks::MockDelegate::new(),
 991                &task_def_host,
 992                None,
 993                None,
 994                None,
 995                Some("python3".to_string()),
 996            )
 997            .await;
 998
 999        assert!(result_host.is_err());
1000        assert!(
1001            result_host
1002                .unwrap_err()
1003                .to_string()
1004                .contains("Cannot have two different hosts")
1005        );
1006    }
1007
1008    #[gpui::test]
1009    async fn test_attach_with_connect_mode_generates_correct_arguments() {
1010        let host = Ipv4Addr::new(127, 0, 0, 1);
1011        let port = 5678;
1012
1013        let args_without_host = PythonDebugAdapter::generate_debugpy_arguments(
1014            &host,
1015            port,
1016            DebugpyLaunchMode::AttachWithConnect { host: None },
1017            None,
1018            None,
1019        )
1020        .await
1021        .unwrap();
1022
1023        let expected_suffix = path!("debug_adapters/Debugpy/debugpy/adapter");
1024        assert!(args_without_host[0].ends_with(expected_suffix));
1025        assert_eq!(args_without_host[1], "connect");
1026        assert_eq!(args_without_host[2], "5678");
1027
1028        let args_with_host = PythonDebugAdapter::generate_debugpy_arguments(
1029            &host,
1030            port,
1031            DebugpyLaunchMode::AttachWithConnect {
1032                host: Some("192.168.1.100"),
1033            },
1034            None,
1035            None,
1036        )
1037        .await
1038        .unwrap();
1039
1040        assert!(args_with_host[0].ends_with(expected_suffix));
1041        assert_eq!(args_with_host[1], "connect");
1042        assert_eq!(args_with_host[2], "192.168.1.100:");
1043        assert_eq!(args_with_host[3], "5678");
1044
1045        let args_normal = PythonDebugAdapter::generate_debugpy_arguments(
1046            &host,
1047            port,
1048            DebugpyLaunchMode::Normal,
1049            None,
1050            None,
1051        )
1052        .await
1053        .unwrap();
1054
1055        assert!(args_normal[0].ends_with(expected_suffix));
1056        assert_eq!(args_normal[1], "--host=127.0.0.1");
1057        assert_eq!(args_normal[2], "--port=5678");
1058        assert!(!args_normal.contains(&"connect".to_string()));
1059    }
1060
1061    #[gpui::test]
1062    async fn test_debugpy_install_path_cases() {
1063        let host = Ipv4Addr::new(127, 0, 0, 1);
1064        let port = 5678;
1065
1066        // Case 1: User-defined debugpy path (highest precedence)
1067        let user_path = PathBuf::from("/custom/path/to/debugpy/src/debugpy/adapter");
1068        let user_args = PythonDebugAdapter::generate_debugpy_arguments(
1069            &host,
1070            port,
1071            DebugpyLaunchMode::Normal,
1072            Some(&user_path),
1073            None,
1074        )
1075        .await
1076        .unwrap();
1077
1078        let venv_args = PythonDebugAdapter::generate_debugpy_arguments(
1079            &host,
1080            port,
1081            DebugpyLaunchMode::Normal,
1082            None,
1083            None,
1084        )
1085        .await
1086        .unwrap();
1087
1088        assert_eq!(user_args[0], "/custom/path/to/debugpy/src/debugpy/adapter");
1089        assert_eq!(user_args[1], "--host=127.0.0.1");
1090        assert_eq!(user_args[2], "--port=5678");
1091
1092        let expected_suffix = path!("debug_adapters/Debugpy/debugpy/adapter");
1093        assert!(venv_args[0].ends_with(expected_suffix));
1094        assert_eq!(venv_args[1], "--host=127.0.0.1");
1095        assert_eq!(venv_args[2], "--port=5678");
1096
1097        // The same cases, with arguments overridden by the user
1098        let user_args = PythonDebugAdapter::generate_debugpy_arguments(
1099            &host,
1100            port,
1101            DebugpyLaunchMode::Normal,
1102            Some(&user_path),
1103            Some(vec!["foo".into()]),
1104        )
1105        .await
1106        .unwrap();
1107        let venv_args = PythonDebugAdapter::generate_debugpy_arguments(
1108            &host,
1109            port,
1110            DebugpyLaunchMode::Normal,
1111            None,
1112            Some(vec!["foo".into()]),
1113        )
1114        .await
1115        .unwrap();
1116
1117        assert!(user_args[0].ends_with("src/debugpy/adapter"));
1118        assert_eq!(user_args[1], "foo");
1119
1120        assert!(venv_args[0].ends_with(expected_suffix));
1121        assert_eq!(venv_args[1], "foo");
1122
1123        // Note: Case 3 (GitHub-downloaded debugpy) is not tested since this requires mocking the Github API.
1124    }
1125}