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