No-Config Python debugging using Neovim

  Sunday, March 2, 2025 » Neovim Python Debugging

Table of content


A little while ago Microsoft released No Config Debugging functionality in their vscode python extension.

This got me curious - wondering if it could be replicated in Neovim. Turns out it can be. This post shows how.

Shopping list ΒΆ

On a high level what we’d like to have is:

For debugging support Visual Studio Code uses the Debug Adapter Protocol (DAP). This is similar to the Language Server Protocol (LSP) in that it aims to abstract the way development tools (your editor) communicate with debuggers or runtimes.

For Python there’s a debug-adapter implementation called debugpy. Once installed - e.g.Β on Archlinux via pacman -S python-debugpy - you can run your application in a shell/terminal like this:

python -m debugpy --listen localhost:5678 --wait-for-client myfile.py

This will create a service listening on port 5678 - waiting for some DAP client to connect before it executes myfile.py. For Neovim we’ve two popular DAP clients:

In this post we’re going to use nvim-dap.

nvim-dap - being a generic Debug Adapter Protocol client - needs some configuration to teach it about debugpy. You can learn more about the adapter configuration by reading :help dap-adapter. We’ll take a short-cut here and use nvim-dap-python, which takes care of the adapter setup. See its README for setup instructions.

The full machinery ends up looking something like this:

   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” DAP   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚dev-tool│──────►│debug-adapter│─►│debugger│──►│ script  β”‚
   β”‚  nvim  β”‚       β”‚   debugpy   β”‚  β”‚ pydevd β”‚   β”‚myfile.pyβ”‚
   β”‚nvim-dapβ”‚       β”‚             β”‚  β”‚        β”‚   β”‚         β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Usually you’d use nvim-dap to set some breakpoints and then start a debug session via :DapNew or require("dap").continue(). These commands prompt you for a application-debug-configuration declared in a .vscode/launch.json file or within a dap.configurations.<filetype> table that’s part of your init.lua. You can learn more about that by reading :help dap-configuration.

nvim-dap-python includes a few global configuration out of the box. Including one that lets us attach to a running debugpy service. It calls that configuration attach.

So, with debugpy, nvim-dap and nvim-dap-python we can run the following command in a shell:

python -m debugpy --listen localhost:5678 --wait-for-client myfile.py

And use :DapNew attach within a Python file opened in nvim and we’d have it run myfile.py in debug mode.

Except that’s not the goal of this post.

No-Config glue ΒΆ

What we’d like instead is some kind of debugpy-run myfile.py somearg executable that automatically runs debugpy --listen with the provided file and argument and also automatically tells nvim-dap to connect to it.

Luckily for us we’ve all the ingredients we need to glue these components together:

All these things put together allow us to create a debugpy-run Lua script. This allows us to have the following flow:

  β”Œβ–Ίnvim
  β”‚myfile.py
  β”‚  β”‚
  β”‚  β”‚  :terminal
  β”‚  └────────────► debugpy-run
  β”‚                   nvim -l
  β”‚                     β”‚
  β”‚                     │─► spawn debugpy --listen
  β”‚    rpc connect      β”‚    inherits stdin, stdout, stderr
  └──────────────────────
     exec_lua dap.run() β”‚
                        β–Ό
                   vim.wait (block)

Here’s the code including some comments:

#!/usr/bin/env -S nvim -l
-- vim: ft=lua


-- We use the "luv" Lua bindings that vim includes to spawn the debugpy process
-- In older versions this was exposed as `vim.loop`, in newer version it is `vim.uv`
local uv = vim.uv or vim.loop

local opts = {
  -- `uv.spawn` lets us inherit fds if we provide them as integer here
  -- 0, 1, 2 are stdin, stdout, stderr
  -- This is done to ensure you see the output of the script you're debugging when using `debugpy-run`
  stdio = {0, 1, 2},
  detached = true,

  -- lua sets a global `arg` variable to the arguments of the script you run
  args = {"--listen", "localhost:1234", "--wait-for-client", arg[1]}
}

local handle, piderr
local function on_exit(code)
  print("exit " .. code)
  handle:close()
  handle = nil
end
handle, piderr = uv.spawn("debugpy", opts, on_exit)
if not handle then
  print(piderr)
  return
end

-- We get the path to the socket
local parent = assert(os.getenv("NVIM"), "debugpy-run only works if $NVIM is set")

-- And connect to it
local conn = vim.fn.sockconnect("pipe", parent, { rpc = true })

-- And start a new debug session via `dap.run()`
vim.fn.rpcrequest(conn, "nvim_exec_lua", [[require("dap").run(...)]], {{
  name = "debugpy-run", -- An arbitrary name

  -- This refers to the dap.adapters.debugpy definition created by nvim-dap-python
  type = "debugpy",

  -- This tells nvim-dap to connect to the debugpy instance listening on 1234
  -- See:
  -- - https://github.com/microsoft/debugpy/wiki/Command-Line-Reference
  -- - https://github.com/microsoft/debugpy/wiki/Debug-configuration-settings
  request = "attach",
  connect = {
    host = "127.0.0.1",
    port = 1234
  },
  purpose = {"debug-in-terminal"},
  pythonArgs = {"-Xfrozen_modules=off"},
}})

-- And block until the debugpy process stop
while handle do
  vim.wait(100, function() return false end)
end

Wrap up ΒΆ

That’s it. Have a look at a demo.

I hope you learned something new. In my workflow I tend to use test cases as entry point or application specific configurations so I won’t be publishing this in a ready-to-use way. But feel free to use the code from this post and extend it as you see fit.

Note that you could use the same approach with other languages if there are debug adapters that support listening on TCP in a blocking mode.