Files
neovim/runtime/lua/vim/provider/python.lua
Michael Henry 5f8d4a248a feat(provider): detect venv python via "pynvim-python" tool #35273
Problem:
Detection of the pynvim module is currently done by finding the first
Python interpreter in the `PATH` and checking if it can import pynvim.
This has several problems:
- Activation of an unrelated Python virtual environment will break
  automatic detection, unless pynvim is also installed in that
  environment.
- Installing pynvim to the expected location is difficult. User
  installation into the system-wide or user-wide Python site area is now
  deprecated.  On Ubuntu 24.04 with Python 3.12, for example, the
  command `pip install --user pynvim` now fails with the error message
  `error: externally-managed-environment`.
- Users may create a dedicated virtual environment in which to install
  pynvim, but Nvim won't detect it; instead, they must either activate
  it before launching Nvim (which interferes with the user of other
  virtual environments) or else hard-code the variable
  `g:python3_host_prog` in their `init.vim` to the path of the correct
  Python interpreter.  Neither option is desirable.

Solution:
Expose pynvim's Python interpreter on the `PATH` under the
name `pynvim-python`.  Typical user-flow:

1. User installs either uv or pipx.
2. User installs pynvim via:
   ```
   uv tool install --upgrade pynvim
   # Or:
   pipx install --upgrade pynvim
   ```

With corresponding changes in pynvim https://github.com/neovim/pynvim/issues/593
the above user-flow is all that's needed for Nvim to detect the
installed location of pynvim, even if an unrelated Python virtual
environments is activated.  It uses standard Python tooling to automate
the necessary creation of a Python virtual environment for pyenv and the
publication of `pynvim-python` to a directory on `PATH`.
2025-08-16 14:48:08 -07:00

154 lines
4.3 KiB
Lua

local M = {}
local min_version = '3.9'
local s_err ---@type string?
local s_host ---@type string?
local python_candidates = {
'python3',
'python3.13',
'python3.12',
'python3.11',
'python3.10',
'python3.9',
'python',
}
--- @param prog string
--- @param module string
--- @return integer, string
local function import_module(prog, module)
local program = [[
import sys, importlib.util;
sys.path = [p for p in sys.path if p != ""];
sys.stdout.write(str(sys.version_info[0]) + "." + str(sys.version_info[1]));]]
program = program
.. string.format('sys.exit(2 * int(importlib.util.find_spec("%s") is None))', module)
local out = vim.system({ prog, '-W', 'ignore', '-c', program }):wait()
return out.code, assert(out.stdout)
end
--- @param prog string
--- @param module string
--- @return string?
local function check_for_module(prog, module)
local prog_path = vim.fn.exepath(prog)
if prog_path == '' then
return prog .. ' not found in search path or not executable.'
end
-- Try to load module, and output Python version.
-- Exit codes:
-- 0 module can be loaded.
-- 2 module cannot be loaded.
-- Otherwise something else went wrong (e.g. 1 or 127).
local prog_exitcode, prog_version = import_module(prog, module)
if prog_exitcode == 2 or prog_exitcode == 0 then
-- Check version only for expected return codes.
if vim.version.lt(prog_version, min_version) then
return string.format(
'%s is Python %s and cannot provide Python >= %s.',
prog_path,
prog_version,
min_version
)
end
end
if prog_exitcode == 2 then
return string.format('%s does not have the "%s" module.', prog_path, module)
elseif prog_exitcode == 127 then
-- This can happen with pyenv's shims.
return string.format('%s does not exist: %s', prog_path, prog_version)
elseif prog_exitcode ~= 0 then
return string.format(
'Checking %s caused an unknown error. (%s, output: %s) Report this at https://github.com/neovim/neovim',
prog_path,
prog_exitcode,
prog_version
)
end
return nil
end
--- @param module string
--- @return string? path to detected python, if any; nil if not found
--- @return string? error message if python can't be detected by {module}; nil if success
function M.detect_by_module(module)
local python_exe = vim.fn.expand(vim.g.python3_host_prog or '', true)
if python_exe ~= '' then
return vim.fn.exepath(vim.fn.expand(python_exe, true)), nil
end
if vim.fn.executable('pynvim-python') == 1 then
return 'pynvim-python'
end
local errors = {}
for _, exe in ipairs(python_candidates) do
local error = check_for_module(exe, module)
if not error then
return exe, error
end
-- Accumulate errors in case we don't find any suitable Python executable.
table.insert(errors, error)
end
-- No suitable Python executable found.
return nil, 'Could not load Python :\n' .. table.concat(errors, '\n')
end
function M.require(host)
-- Python host arguments
local prog = M.detect_by_module('neovim')
local args = {
prog,
'-c',
'import sys; sys.path = [p for p in sys.path if p != ""]; import neovim; neovim.start_host()',
}
-- Collect registered Python plugins into args
local python_plugins = vim.fn['remote#host#PluginsForHost'](host.name) ---@type any
---@param plugin any
for _, plugin in ipairs(python_plugins) do
table.insert(args, plugin.path)
end
return vim.fn['provider#Poll'](
args,
host.orig_name,
'$NVIM_PYTHON_LOG_FILE',
{ ['overlapped'] = true }
)
end
function M.call(method, args)
if s_err then
return
end
if not s_host then
-- Ensure that we can load the Python3 host before bootstrapping
local ok, result = pcall(vim.fn['remote#host#Require'], 'legacy-python3-provider') ---@type any, any
if not ok then
s_err = result
vim.api.nvim_echo({ { result, 'WarningMsg' } }, true, {})
return
end
s_host = result
end
return vim.fn.rpcrequest(s_host, 'python_' .. method, unpack(args))
end
function M.start()
-- The Python3 provider plugin will run in a separate instance of the Python3 host.
vim.fn['remote#host#RegisterClone']('legacy-python3-provider', 'python3')
vim.fn['remote#host#RegisterPlugin']('legacy-python3-provider', 'script_host.py', {})
end
return M