Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,4 @@ dmypy.json

Session.vim

tmp/
2 changes: 1 addition & 1 deletion lua/neotest-python/adapter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ return function(config)
local python_command = config.get_python_command(root)
local runner = config.get_runner(python_command)

local positions = lib.treesitter.parse_positions(path, base.treesitter_queries(runner, config, python_command), {
local positions = lib.treesitter.parse_positions(path, base.treesitter_queries(runner, config, python_command, root), {
require_namespaces = runner == "unittest",
})

Expand Down
151 changes: 126 additions & 25 deletions lua/neotest-python/base.lua
Original file line number Diff line number Diff line change
Expand Up @@ -107,31 +107,127 @@ end
---@param python_command string[]
---@param config neotest-python._AdapterConfig
---@param runner string
---@return string
local function scan_test_function_pattern(runner, config, python_command)
local test_function_pattern = "^test"
if runner == "pytest" and config.pytest_discovery then
local cmd = vim.tbl_flatten({ python_command, M.get_script_path(), "--pytest-extract-test-name-template" })
---@param root string Project root directory (used as cwd for pytest config extraction)
---@return table {test_pattern: string, namespace_pattern: string, class_pattern: string}
local function scan_pytest_config(runner, config, python_command, root)
local test_function_pattern = "^test_|^it_"
local namespace_pattern = "" -- For describe_prefixes
local class_pattern = "" -- For python_classes

-- Extract pytest config patterns regardless of pytest_discovery setting
-- pytest_discovery controls parameterized test discovery, not pattern extraction
if runner == "pytest" then
local cmd = vim.tbl_flatten({
python_command,
M.get_script_path(),
"--pytest-extract-test-name-template",
root or "",
})
local _, data = lib.process.run(cmd, { stdout = true, stderr = true })

for line in vim.gsplit(data.stdout, "\n", true) do
if string.sub(line, 1, 1) == "{" and string.find(line, "python_functions") ~= nil then
if string.sub(line, 1, 1) == "{" then
local pytest_option = vim.json.decode(line)
test_function_pattern = pytest_option.python_functions

-- Extract python_functions pattern
if pytest_option.python_functions then
local patterns = pytest_option.python_functions
-- Handle both string and table types for robustness
if type(patterns) == "table" then
-- Already a table (from legacy JSON array format), use directly
patterns = patterns
else
-- String format, split by spaces
patterns = vim.split(patterns, " ", { trimempty = true })
end
local regex_parts = vim.tbl_map(function(p)
return "^" .. p:gsub("%*", "")
end, patterns)
test_function_pattern = table.concat(regex_parts, "|")
end

-- Extract describe_prefixes pattern (from pytest-describe plugin)
if pytest_option.describe_prefixes then
local prefixes = pytest_option.describe_prefixes
-- Handle both string and table types for robustness
if type(prefixes) == "table" then
-- Already a table (from legacy JSON array format), use directly
prefixes = prefixes
else
-- String format, split by spaces
prefixes = vim.split(prefixes, " ", { trimempty = true })
end
local prefix_patterns = vim.tbl_map(function(p)
return "^" .. p .. "_"
end, prefixes)
namespace_pattern = table.concat(prefix_patterns, "|")
end

-- Extract python_classes pattern (for class-based tests like BDD)
if pytest_option.python_classes then
local classes = pytest_option.python_classes
-- Handle both string and table types for robustness
if type(classes) == "table" then
-- Already a table (from legacy JSON array format), use directly
classes = classes
else
-- String format, split by spaces
classes = vim.split(classes, " ", { trimempty = true })
end
local class_patterns = vim.tbl_map(function(p)
return "^" .. p:gsub("%*", "")
end, classes)
class_pattern = table.concat(class_patterns, "|")
end
end
end
end
return test_function_pattern

-- Default namespace patterns if none configured
if namespace_pattern == "" then
namespace_pattern = "^describe_|^context_|^when_|^given_|^scenario_|^requirement_"
end

-- Default class patterns if none configured
if class_pattern == "" then
class_pattern = "^Test"
end

return {
test_pattern = test_function_pattern,
namespace_pattern = namespace_pattern,
class_pattern = class_pattern,
}
end

---@param python_command string[]
---@param config neotest-python._AdapterConfig
---@param runner string
---@param root? string Project root directory for reading pytest config
---@return string
M.treesitter_queries = function(runner, config, python_command)
local test_function_pattern = scan_test_function_pattern(runner, config, python_command)
return string.format([[
;; Match undecorated functions
M.treesitter_queries = function(runner, config, python_command, root)
local patterns = scan_pytest_config(runner, config, python_command, root)
local test_function_pattern = patterns.test_pattern
local namespace_pattern = patterns.namespace_pattern
local class_pattern = patterns.class_pattern

return string.format(
[[
;; Match container functions (describe_*, context_*, when_*, given_*, scenario_*, requirement_*)
;; These create namespaces for organizing tests
((function_definition
name: (identifier) @namespace.name)
(#match? @namespace.name "%s"))
@namespace.definition

;; Match decorated container functions
(decorated_definition
((function_definition
name: (identifier) @namespace.name)
(#match? @namespace.name "%s")))
@namespace.definition

;; Match test functions (both top-level and inside classes)
((function_definition
name: (identifier) @test.name)
(#match? @test.name "%s"))
Expand All @@ -144,21 +240,26 @@ M.treesitter_queries = function(runner, config, python_command)
(#match? @test.name "%s")))
@test.definition

;; Match decorated classes, including decorators in definition
(decorated_definition
(class_definition
name: (identifier) @namespace.name))
@namespace.definition

;; Match undecorated classes: namespaces nest so #not-has-parent is used
;; to ensure each namespace is annotated only once
(
(class_definition
;; Match classes as namespaces (with pytest python_classes filter)
((class_definition
name: (identifier) @namespace.name)
(#match? @namespace.name "%s"))
@namespace.definition
(#not-has-parent? @namespace.definition decorated_definition)
)
]], test_function_pattern, test_function_pattern)

;; Match decorated classes
(decorated_definition
((class_definition
name: (identifier) @namespace.name)
(#match? @namespace.name "%s")))
@namespace.definition
]],
namespace_pattern,
namespace_pattern,
test_function_pattern,
test_function_pattern,
class_pattern,
class_pattern
)
end

M.get_root =
Expand Down
202 changes: 202 additions & 0 deletions lua/neotest-python/base.lua.bak
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
local nio = require("nio")
local lib = require("neotest.lib")
local Path = require("plenary.path")

local M = {}

function M.is_test_file(file_path)
if not vim.endswith(file_path, ".py") then
return false
end
local elems = vim.split(file_path, Path.path.sep)
local file_name = elems[#elems]
return vim.startswith(file_name, "test_") or vim.endswith(file_name, "_test.py")
end

M.module_exists = function(module, python_command)
return lib.process.run(vim
.iter({
python_command,
"-c",
"import " .. module,
})
:flatten()
:totable()) == 0
end

local python_command_mem = {}
local venv_bin = vim.loop.os_uname().sysname:match("Windows") and "Scripts" or "bin"

---@return string[]
function M.get_python_command(root)
root = root or vim.loop.cwd()
if python_command_mem[root] then
return python_command_mem[root]
end
-- Use activated virtualenv.
if vim.env.VIRTUAL_ENV then
python_command_mem[root] = { Path:new(vim.env.VIRTUAL_ENV, venv_bin, "python").filename }
return python_command_mem[root]
end

for _, pattern in ipairs({ "*", ".*" }) do
local match = nio.fn.glob(Path:new(root or nio.fn.getcwd(), pattern, "pyvenv.cfg").filename)
if match ~= "" then
python_command_mem[root] = { (Path:new(match):parent() / venv_bin / "python").filename }
return python_command_mem[root]
end
end

if lib.files.exists("Pipfile") then
local success, exit_code, data = pcall(lib.process.run, { "pipenv", "--py" }, { stdout = true })
if success and exit_code == 0 then
local venv = data.stdout:gsub("\r?\n", "")
if venv then
python_command_mem[root] = { Path:new(venv).filename }
return python_command_mem[root]
end
end
end

if lib.files.exists("pyproject.toml") then
local success, exit_code, data = pcall(
lib.process.run,
{ "poetry", "run", "poetry", "env", "info", "-p" },
{ stdout = true }
)
if success and exit_code == 0 then
local venv = data.stdout:gsub("\r?\n", "")
if venv then
python_command_mem[root] = { Path:new(venv, venv_bin, "python").filename }
return python_command_mem[root]
end
end
end

if lib.files.exists("uv.lock") then
local success, exit_code, data = pcall(
lib.process.run,
{ "uv", "run", "python", "-c", "import sys; print(sys.executable)" },
{ stdout = true }
)
if success and exit_code == 0 then
python_command_mem[root] = { Path:new(data).filename }
return python_command_mem[root]
end
end

-- Fallback to system Python.
python_command_mem[root] = {
nio.fn.exepath("python3") or nio.fn.exepath("python") or "python",
}
return python_command_mem[root]
end

---@return string
function M.get_script_path()
local paths = vim.api.nvim_get_runtime_file("neotest.py", true)
for _, path in ipairs(paths) do
if vim.endswith(path, ("neotest-python%sneotest.py"):format(lib.files.sep)) then
return path
end
end

error("neotest.py not found")
end

---@param python_command string[]
---@param config neotest-python._AdapterConfig
---@param runner string
---@return string
local function scan_test_function_pattern(runner, config, python_command)
local test_function_pattern = "^test"
if runner == "pytest" and config.pytest_discovery then
local cmd = vim.tbl_flatten({ python_command, M.get_script_path(), "--pytest-extract-test-name-template" })
local _, data = lib.process.run(cmd, { stdout = true, stderr = true })

for line in vim.gsplit(data.stdout, "\n", true) do
if string.sub(line, 1, 1) == "{" and string.find(line, "python_functions") ~= nil then
local pytest_option = vim.json.decode(line)
test_function_pattern = pytest_option.python_functions
end
end
end
return test_function_pattern
end

---@param python_command string[]
---@param config neotest-python._AdapterConfig
---@param runner string
---@return string
M.treesitter_queries = function(runner, config, python_command)
local test_function_pattern = scan_test_function_pattern(runner, config, python_command)
return string.format([[
;; Match undecorated functions
((function_definition
name: (identifier) @test.name)
(#match? @test.name "%s"))
@test.definition

;; Match decorated function, including decorators in definition
(decorated_definition
((function_definition
name: (identifier) @test.name)
(#match? @test.name "%s")))
@test.definition

;; Match decorated classes, including decorators in definition
(decorated_definition
(class_definition
name: (identifier) @namespace.name))
@namespace.definition

;; Match undecorated classes: namespaces nest so #not-has-parent is used
;; to ensure each namespace is annotated only once
(
(class_definition
name: (identifier) @namespace.name)
@namespace.definition
(#not-has-parent? @namespace.definition decorated_definition)
)
]], test_function_pattern, test_function_pattern)
end

M.get_root =
lib.files.match_root_pattern("pyproject.toml", "setup.cfg", "mypy.ini", "pytest.ini", "setup.py")

function M.create_dap_config(python_path, script_path, script_args, dap_args)
return vim.tbl_extend("keep", {
type = "python",
name = "Neotest Debugger",
request = "launch",
python = python_path,
program = script_path,
cwd = nio.fn.getcwd(),
args = script_args,
}, dap_args or {})
end

local stored_runners = {}

function M.get_runner(python_path)
local command_str = table.concat(python_path, " ")
if stored_runners[command_str] then
return stored_runners[command_str]
end
local vim_test_runner = vim.g["test#python#runner"]
if vim_test_runner == "pyunit" then
return "unittest"
end
if
vim_test_runner and lib.func_util.index({ "unittest", "pytest", "django" }, vim_test_runner)
then
return vim_test_runner
end
local runner = M.module_exists("pytest", python_path) and "pytest"
or M.module_exists("django", python_path) and "django"
or "unittest"
stored_runners[command_str] = runner
return runner
end

return M
Loading