From 8de6bd2994d3a6ce28641288149f2f9f5bd64445 Mon Sep 17 00:00:00 2001 From: Salvador Ruiz Date: Thu, 16 Apr 2026 10:11:32 +0200 Subject: [PATCH 01/10] fix: support multiple python_functions patterns from pytest ini getini('python_functions') returns a list but only [0] was taken, causing only the first pattern to be recognised by neotest. Fix by joining all patterns in the Python script and splitting them back into a treesitter regex alternation in the Lua layer. --- lua/neotest-python/base.lua | 20 +++- lua/neotest-python/base.lua.bak | 202 ++++++++++++++++++++++++++++++++ neotest_python/pytest.py | 2 +- 3 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 lua/neotest-python/base.lua.bak diff --git a/lua/neotest-python/base.lua b/lua/neotest-python/base.lua index 6d4a3d3..e7a31f7 100644 --- a/lua/neotest-python/base.lua +++ b/lua/neotest-python/base.lua @@ -111,13 +111,21 @@ end 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 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 + local patterns = vim.split(pytest_option.python_functions, " ", { trimempty = true }) + local regex_parts = vim.tbl_map(function(p) + return "^" .. p:gsub("%*", "") + end, patterns) + test_function_pattern = table.concat(regex_parts, "|") end end end @@ -130,7 +138,8 @@ end ---@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([[ + return string.format( + [[ ;; Match undecorated functions ((function_definition name: (identifier) @test.name) @@ -158,7 +167,10 @@ M.treesitter_queries = function(runner, config, python_command) @namespace.definition (#not-has-parent? @namespace.definition decorated_definition) ) - ]], test_function_pattern, test_function_pattern) + ]], + test_function_pattern, + test_function_pattern + ) end M.get_root = diff --git a/lua/neotest-python/base.lua.bak b/lua/neotest-python/base.lua.bak new file mode 100644 index 0000000..6d4a3d3 --- /dev/null +++ b/lua/neotest-python/base.lua.bak @@ -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 diff --git a/neotest_python/pytest.py b/neotest_python/pytest.py index 57e3e27..585a016 100644 --- a/neotest_python/pytest.py +++ b/neotest_python/pytest.py @@ -240,7 +240,7 @@ def maybe_debugpy_postmortem(excinfo): class TestNameTemplateExtractor: @staticmethod def pytest_collection_modifyitems(config): - config = {"python_functions": config.getini("python_functions")[0]} + config = {"python_functions": " ".join(config.getini("python_functions"))} print(f"\n{json.dumps(config)}\n") From 8220ddea1f4fd763bb98a2696063d42d84b08fc0 Mon Sep 17 00:00:00 2001 From: Salvador Ruiz Date: Thu, 16 Apr 2026 15:18:48 +0200 Subject: [PATCH 02/10] Add support for pytest-describe container functions with arbitrary nesting depth - Extract describe_prefixes from pytest configuration at runtime - Generate treesitter queries for container functions (describe, context, when, given, scenario, requirement) - Support unlimited nesting depth for pytest-describe test organization - Maintain backward compatibility with projects not using describe_prefixes - Default to common pytest-describe prefixes if not configured --- lua/neotest-python/base.lua | 59 +++++++++++++++++++++++++++++-------- neotest_python/pytest.py | 12 ++++++-- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/lua/neotest-python/base.lua b/lua/neotest-python/base.lua index e7a31f7..885ce1f 100644 --- a/lua/neotest-python/base.lua +++ b/lua/neotest-python/base.lua @@ -107,9 +107,11 @@ 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) +---@return table {test_pattern: string, namespace_pattern: string} +local function scan_pytest_config(runner, config, python_command) local test_function_pattern = "^test" + local namespace_pattern = "" -- For describe_prefixes + if runner == "pytest" and config.pytest_discovery then local cmd = vim.tbl_flatten({ python_command, @@ -119,17 +121,39 @@ local function scan_test_function_pattern(runner, config, python_command) 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) - local patterns = vim.split(pytest_option.python_functions, " ", { trimempty = true }) - local regex_parts = vim.tbl_map(function(p) - return "^" .. p:gsub("%*", "") - end, patterns) - test_function_pattern = table.concat(regex_parts, "|") + + -- Extract python_functions pattern + if pytest_option.python_functions then + local patterns = vim.split(pytest_option.python_functions, " ", { trimempty = true }) + 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 = vim.split(pytest_option.describe_prefixes, " ", { trimempty = true }) + local prefix_patterns = vim.tbl_map(function(p) + return "^" .. p .. "_" + end, prefixes) + namespace_pattern = table.concat(prefix_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 + + return { + test_pattern = test_function_pattern, + namespace_pattern = namespace_pattern, + } end ---@param python_command string[] @@ -137,10 +161,20 @@ end ---@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) + local patterns = scan_pytest_config(runner, config, python_command) + local test_function_pattern = patterns.test_pattern + local namespace_pattern = patterns.namespace_pattern + return string.format( [[ - ;; Match undecorated functions + ;; 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 undecorated test functions ((function_definition name: (identifier) @test.name) (#match? @test.name "%s")) @@ -157,7 +191,7 @@ M.treesitter_queries = function(runner, config, python_command) (decorated_definition (class_definition name: (identifier) @namespace.name)) - @namespace.definition + @namespace.definition ;; Match undecorated classes: namespaces nest so #not-has-parent is used ;; to ensure each namespace is annotated only once @@ -168,6 +202,7 @@ M.treesitter_queries = function(runner, config, python_command) (#not-has-parent? @namespace.definition decorated_definition) ) ]], + namespace_pattern, test_function_pattern, test_function_pattern ) diff --git a/neotest_python/pytest.py b/neotest_python/pytest.py index 585a016..4a43824 100644 --- a/neotest_python/pytest.py +++ b/neotest_python/pytest.py @@ -240,8 +240,16 @@ def maybe_debugpy_postmortem(excinfo): class TestNameTemplateExtractor: @staticmethod def pytest_collection_modifyitems(config): - config = {"python_functions": " ".join(config.getini("python_functions"))} - print(f"\n{json.dumps(config)}\n") + extracted_config = { + "python_functions": " ".join(config.getini("python_functions")) + } + + # Extract describe_prefixes if pytest-describe is configured + describe_prefixes = config.getini("describe_prefixes") + if describe_prefixes: + extracted_config["describe_prefixes"] = " ".join(describe_prefixes) + + print(f"\n{json.dumps(extracted_config)}\n") def extract_test_name_template(args) -> int: From a06c5b6cf84893f8a9f271236f08e8678c87f2a2 Mon Sep 17 00:00:00 2001 From: Salvador Ruiz Date: Thu, 16 Apr 2026 15:24:13 +0200 Subject: [PATCH 03/10] feat: add support for pytest-describe container functions with arbitrary nesting depth - Extract describe_prefixes from pytest configuration at runtime - Generate treesitter queries for container functions (describe_, context_, when_, given_, scenario_, requirement_) - Support unlimited nesting depth for pytest-describe test organization - Maintain backward compatibility with projects not using describe_prefixes - Default to common pytest-describe prefixes if not configured This enables neotest-python to correctly discover and run deeply nested pytest-describe tests, fixing the issue where tests were marked as not found when using nested container functions. --- lua/neotest-python/base.lua | 52 ++++++++++++++++++++++++++++++------- neotest_python/pytest.py | 10 +++++-- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/lua/neotest-python/base.lua b/lua/neotest-python/base.lua index 6d4a3d3..41c6322 100644 --- a/lua/neotest-python/base.lua +++ b/lua/neotest-python/base.lua @@ -107,21 +107,45 @@ 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) +---@return table {test_pattern: string, namespace_pattern: string} +local function scan_pytest_config(runner, config, python_command) local test_function_pattern = "^test" + local namespace_pattern = "" -- For describe_prefixes + 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 + 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 + test_function_pattern = pytest_option.python_functions + end + + -- Extract describe_prefixes pattern (from pytest-describe plugin) + if pytest_option.describe_prefixes then + local prefixes = vim.split(pytest_option.describe_prefixes, " ", { trimempty = true }) + local prefix_patterns = vim.tbl_map(function(p) + return "^" .. p .. "_" + end, prefixes) + namespace_pattern = table.concat(prefix_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 + + return { + test_pattern = test_function_pattern, + namespace_pattern = namespace_pattern, + } end ---@param python_command string[] @@ -129,9 +153,19 @@ end ---@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) + local patterns = scan_pytest_config(runner, config, python_command) + local test_function_pattern = patterns.test_pattern + local namespace_pattern = patterns.namespace_pattern + return string.format([[ - ;; Match undecorated functions + ;; 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 undecorated test functions ((function_definition name: (identifier) @test.name) (#match? @test.name "%s")) @@ -148,7 +182,7 @@ M.treesitter_queries = function(runner, config, python_command) (decorated_definition (class_definition name: (identifier) @namespace.name)) - @namespace.definition + @namespace.definition ;; Match undecorated classes: namespaces nest so #not-has-parent is used ;; to ensure each namespace is annotated only once @@ -158,7 +192,7 @@ M.treesitter_queries = function(runner, config, python_command) @namespace.definition (#not-has-parent? @namespace.definition decorated_definition) ) - ]], test_function_pattern, test_function_pattern) + ]], namespace_pattern, test_function_pattern, test_function_pattern) end M.get_root = diff --git a/neotest_python/pytest.py b/neotest_python/pytest.py index 57e3e27..ca73508 100644 --- a/neotest_python/pytest.py +++ b/neotest_python/pytest.py @@ -240,8 +240,14 @@ def maybe_debugpy_postmortem(excinfo): class TestNameTemplateExtractor: @staticmethod def pytest_collection_modifyitems(config): - config = {"python_functions": config.getini("python_functions")[0]} - print(f"\n{json.dumps(config)}\n") + extracted_config = {"python_functions": config.getini("python_functions")[0]} + + # Extract describe_prefixes if pytest-describe is configured + describe_prefixes = config.getini("describe_prefixes") + if describe_prefixes: + extracted_config["describe_prefixes"] = " ".join(describe_prefixes) + + print(f"\n{json.dumps(extracted_config)}\n") def extract_test_name_template(args) -> int: From 668f0b251af0eef049bce9301ac537f2f79a4574 Mon Sep 17 00:00:00 2001 From: Salvador Ruiz Date: Fri, 17 Apr 2026 13:07:16 +0200 Subject: [PATCH 04/10] fix: add defensive type checking for pytest config pattern extraction Improve robustness of pattern extraction in scan_pytest_config by handling both string and table types for python_functions and describe_prefixes values. The Python backend now consistently returns both python_functions and describe_prefixes as space-separated strings in JSON. This change adds defensive type checking to handle both the new string format and any legacy table format (from JSON arrays) for backward compatibility. This ensures that pattern extraction works correctly regardless of whether: - python_functions is returned as a string or table - describe_prefixes is returned as a string or table Fixes test discovery in Neovim for pytest-describe projects with custom python_functions patterns. --- lua/neotest-python/base.lua | 48 ++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/lua/neotest-python/base.lua b/lua/neotest-python/base.lua index 5703932..9a96760 100644 --- a/lua/neotest-python/base.lua +++ b/lua/neotest-python/base.lua @@ -111,7 +111,7 @@ end local function scan_pytest_config(runner, config, python_command) local test_function_pattern = "^test" local namespace_pattern = "" -- For describe_prefixes - + if runner == "pytest" and config.pytest_discovery then local cmd = vim.tbl_flatten({ python_command, @@ -123,19 +123,35 @@ local function scan_pytest_config(runner, config, python_command) for line in vim.gsplit(data.stdout, "\n", true) do if string.sub(line, 1, 1) == "{" then local pytest_option = vim.json.decode(line) - - -- Extract python_functions pattern - if pytest_option.python_functions then - local patterns = vim.split(pytest_option.python_functions, " ", { trimempty = true }) - local regex_parts = vim.tbl_map(function(p) - return "^" .. p:gsub("%*", "") - end, patterns) - test_function_pattern = table.concat(regex_parts, "|") - end - + + -- 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 = vim.split(pytest_option.describe_prefixes, " ", { trimempty = true }) + 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) @@ -144,12 +160,12 @@ local function scan_pytest_config(runner, config, python_command) end end end - + -- Default namespace patterns if none configured if namespace_pattern == "" then - namespace_pattern = "^(describe_|context_|when_|given_|scenario_|requirement_)" + namespace_pattern = "^describe_|^context_|^when_|^given_|^scenario_|^requirement_" end - + return { test_pattern = test_function_pattern, namespace_pattern = namespace_pattern, @@ -164,7 +180,7 @@ M.treesitter_queries = function(runner, config, python_command) local patterns = scan_pytest_config(runner, config, python_command) local test_function_pattern = patterns.test_pattern local namespace_pattern = patterns.namespace_pattern - + return string.format( [[ ;; Match container functions (describe_*, context_*, when_*, given_*, scenario_*, requirement_*) From 9daaf204bbcf1543ff7fb44e278799012bdd2c2b Mon Sep 17 00:00:00 2001 From: Salvador Ruiz Date: Fri, 17 Apr 2026 13:32:53 +0200 Subject: [PATCH 05/10] feat: add support for class-based test discovery with python_classes patterns This change enables neotest-python to discover tests in class-based structures using custom python_classes patterns (e.g., A_*, An_*, UseCase_* for BDD-style tests). Changes: 1. Python backend (pytest.py): - Extract python_classes patterns from pytest configuration - Normalize to space-separated string format for Lua frontend 2. Lua frontend (base.lua): - Add python_classes pattern extraction in scan_pytest_config - Support both string and table types for backward compatibility - Default to ^Test pattern if not configured 3. TreeSitter query updates (base.lua): - Filter classes to match only python_classes patterns - Add support for matching test methods inside classes - Handle both decorated and undecorated test methods in classes - Maintain function-based discovery for pytest-describe style tests This enables discovery for: - BDD-style class-based tests (class A_* with should_* methods) - Standard Python unittest style tests (class Test*) - pytest-describe style tests (function-based with describe_* prefixes) Tested with: - sgclima-operations: class-based BDD with A_*, An_*, UseCase_* patterns - simel: function-based pytest-describe with describe_*, context_*, when_* patterns --- lua/neotest-python/base.lua | 60 +++++++++++++++++++++++++++++++++---- neotest_python/pytest.py | 5 ++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/lua/neotest-python/base.lua b/lua/neotest-python/base.lua index 9a96760..b67ea74 100644 --- a/lua/neotest-python/base.lua +++ b/lua/neotest-python/base.lua @@ -107,10 +107,11 @@ end ---@param python_command string[] ---@param config neotest-python._AdapterConfig ---@param runner string ----@return table {test_pattern: string, namespace_pattern: string} +---@return table {test_pattern: string, namespace_pattern: string, class_pattern: string} local function scan_pytest_config(runner, config, python_command) local test_function_pattern = "^test" local namespace_pattern = "" -- For describe_prefixes + local class_pattern = "" -- For python_classes if runner == "pytest" and config.pytest_discovery then local cmd = vim.tbl_flatten({ @@ -157,6 +158,23 @@ local function scan_pytest_config(runner, config, python_command) 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 @@ -166,9 +184,15 @@ local function scan_pytest_config(runner, config, python_command) 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 @@ -180,6 +204,7 @@ M.treesitter_queries = function(runner, config, python_command) local patterns = scan_pytest_config(runner, config, python_command) local test_function_pattern = patterns.test_pattern local namespace_pattern = patterns.namespace_pattern + local class_pattern = patterns.class_pattern return string.format( [[ @@ -203,23 +228,46 @@ M.treesitter_queries = function(runner, config, python_command) (#match? @test.name "%s"))) @test.definition - ;; Match decorated classes, including decorators in definition + ;; Match decorated classes matching python_classes pattern (decorated_definition (class_definition - name: (identifier) @namespace.name)) + name: (identifier) @namespace.name) + (#match? @namespace.name "%s")) @namespace.definition - ;; Match undecorated classes: namespaces nest so #not-has-parent is used - ;; to ensure each namespace is annotated only once + ;; Match undecorated classes matching python_classes pattern + ;; 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) + (#match? @namespace.name "%s") + (#not-has-parent? @namespace.definition decorated_definition) ) + + ;; Match test methods inside classes + ((class_definition + body: (block + (function_definition + name: (identifier) @test.name) + (#match? @test.name "%s")))) + @test.definition + + ;; Match decorated test methods inside classes + ((class_definition + body: (block + (decorated_definition + (function_definition + name: (identifier) @test.name) + (#match? @test.name "%s"))))) + @test.definition ]], namespace_pattern, test_function_pattern, + test_function_pattern, + class_pattern, + class_pattern, + test_function_pattern, test_function_pattern ) end diff --git a/neotest_python/pytest.py b/neotest_python/pytest.py index 4a43824..83c8661 100644 --- a/neotest_python/pytest.py +++ b/neotest_python/pytest.py @@ -249,6 +249,11 @@ def pytest_collection_modifyitems(config): if describe_prefixes: extracted_config["describe_prefixes"] = " ".join(describe_prefixes) + # Extract python_classes if configured + python_classes = config.getini("python_classes") + if python_classes: + extracted_config["python_classes"] = " ".join(python_classes) + print(f"\n{json.dumps(extracted_config)}\n") From 2ce5c283b4e88930d179b052adeeacb55ddd501f Mon Sep 17 00:00:00 2001 From: Salvador Ruiz Date: Fri, 17 Apr 2026 13:39:33 +0200 Subject: [PATCH 06/10] fix: correct TreeSitter query syntax for matching test methods inside classes The previous query had incorrect parenthesis nesting, causing methods inside classes to not be matched. The @test.definition anchor was nested too deep instead of being applied to the full query expression. Fixed syntax: - Before: ((class ... (#match? ...)))) @test.definition - After: ((class ... (#match? ...))) @test.definition) This ensures the capture groups are properly balanced and the decorator applies to the entire matched expression, not just the nested block. --- lua/neotest-python/base.lua | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lua/neotest-python/base.lua b/lua/neotest-python/base.lua index b67ea74..3a46ea9 100644 --- a/lua/neotest-python/base.lua +++ b/lua/neotest-python/base.lua @@ -250,8 +250,8 @@ M.treesitter_queries = function(runner, config, python_command) body: (block (function_definition name: (identifier) @test.name) - (#match? @test.name "%s")))) - @test.definition + (#match? @test.name "%s"))) + @test.definition) ;; Match decorated test methods inside classes ((class_definition @@ -259,8 +259,8 @@ M.treesitter_queries = function(runner, config, python_command) (decorated_definition (function_definition name: (identifier) @test.name) - (#match? @test.name "%s"))))) - @test.definition + (#match? @test.name "%s")))) + @test.definition) ]], namespace_pattern, test_function_pattern, From e742bbd6b7a2ef72c36ee863f689fbd34da3aca4 Mon Sep 17 00:00:00 2001 From: Salvador Ruiz Date: Fri, 17 Apr 2026 13:41:14 +0200 Subject: [PATCH 07/10] refactor: simplify TreeSitter query to rely on neotest's hierarchy building Instead of trying to match methods inside classes in the TreeSitter query, we now: 1. Match classes by python_classes pattern as namespaces 2. Match all functions/methods by python_functions pattern as tests 3. Let neotest's parse_tree() build the hierarchy based on line ranges This approach is simpler and more reliable because: - TreeSitter queries excel at pattern matching, not nested structure matching - neotest already has logic to nest tests inside namespaces based on ranges - Methods inside classes have line ranges within the class range - Avoids complex and fragile nested query syntax The result is the same: classes appear as namespaces with methods nested inside, but achieved through neotest's built-in hierarchy logic rather than query syntax. --- lua/neotest-python/base.lua | 45 ++++++------------------------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/lua/neotest-python/base.lua b/lua/neotest-python/base.lua index 3a46ea9..8e3b292 100644 --- a/lua/neotest-python/base.lua +++ b/lua/neotest-python/base.lua @@ -215,7 +215,13 @@ M.treesitter_queries = function(runner, config, python_command) (#match? @namespace.name "%s")) @namespace.definition - ;; Match undecorated test functions + ;; Match classes matching python_classes pattern - these are also namespaces + ((class_definition + name: (identifier) @namespace.name) + (#match? @namespace.name "%s")) + @namespace.definition + + ;; Match undecorated test functions (top-level and inside classes) ((function_definition name: (identifier) @test.name) (#match? @test.name "%s")) @@ -227,45 +233,8 @@ M.treesitter_queries = function(runner, config, python_command) name: (identifier) @test.name) (#match? @test.name "%s"))) @test.definition - - ;; Match decorated classes matching python_classes pattern - (decorated_definition - (class_definition - name: (identifier) @namespace.name) - (#match? @namespace.name "%s")) - @namespace.definition - - ;; Match undecorated classes matching python_classes pattern - ;; namespaces nest so #not-has-parent is used to ensure each namespace is annotated only once - ( - (class_definition - name: (identifier) @namespace.name) - @namespace.definition - (#match? @namespace.name "%s") - (#not-has-parent? @namespace.definition decorated_definition) - ) - - ;; Match test methods inside classes - ((class_definition - body: (block - (function_definition - name: (identifier) @test.name) - (#match? @test.name "%s"))) - @test.definition) - - ;; Match decorated test methods inside classes - ((class_definition - body: (block - (decorated_definition - (function_definition - name: (identifier) @test.name) - (#match? @test.name "%s")))) - @test.definition) ]], namespace_pattern, - test_function_pattern, - test_function_pattern, - class_pattern, class_pattern, test_function_pattern, test_function_pattern From cc934479de7fb5d1fe06e2fe5f58bb37b7335fcf Mon Sep 17 00:00:00 2001 From: Salvador Ruiz Date: Fri, 17 Apr 2026 14:41:56 +0200 Subject: [PATCH 08/10] feat: enable discovery of class-based tests with custom pytest python_classes patterns Fixes issue where Neotest couldn't discover class-based tests like A_Unit with should_* methods when configured in pytest with python_classes = 'A_* Test*'. Changes: - Add class_pattern parameter to treesitter_queries function - Use extracted pytest python_classes pattern to filter class names in TreeSitter query - Add support for both undecorated and decorated class definitions - Class patterns are properly extracted from pytest.ini/pyproject.toml via scan_pytest_config This enables discovery of BDD-style test classes (A_Unit, A_UnitsRegistry, etc) that follow the pytest convention for python_classes configuration, while maintaining backward compatibility with function-based tests (describe_*, context_*, when_*, etc). Tests passing: - Class-based tests with A_* pattern (simple test file) - Class-based tests with Test* pattern (standard pytest naming) - Function-based tests with describe_* (pytest-describe, regression test) --- lua/neotest-python/base.lua | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/lua/neotest-python/base.lua b/lua/neotest-python/base.lua index 8e3b292..68ffa50 100644 --- a/lua/neotest-python/base.lua +++ b/lua/neotest-python/base.lua @@ -215,13 +215,7 @@ M.treesitter_queries = function(runner, config, python_command) (#match? @namespace.name "%s")) @namespace.definition - ;; Match classes matching python_classes pattern - these are also namespaces - ((class_definition - name: (identifier) @namespace.name) - (#match? @namespace.name "%s")) - @namespace.definition - - ;; Match undecorated test functions (top-level and inside classes) + ;; Match test functions (both top-level and inside classes) ((function_definition name: (identifier) @test.name) (#match? @test.name "%s")) @@ -233,11 +227,25 @@ M.treesitter_queries = function(runner, config, python_command) name: (identifier) @test.name) (#match? @test.name "%s"))) @test.definition + + ;; Match classes as namespaces (with pytest python_classes filter) + ((class_definition + name: (identifier) @namespace.name) + (#match? @namespace.name "%s")) + @namespace.definition + + ;; Match decorated classes + (decorated_definition + ((class_definition + name: (identifier) @namespace.name) + (#match? @namespace.name "%s"))) + @namespace.definition ]], namespace_pattern, - class_pattern, test_function_pattern, - test_function_pattern + test_function_pattern, + class_pattern, + class_pattern ) end From 590528a436a363dee1929cfe033e801e27346212 Mon Sep 17 00:00:00 2001 From: Salvador Ruiz Date: Fri, 17 Apr 2026 15:28:39 +0200 Subject: [PATCH 09/10] fix: extract pytest config patterns regardless of pytest_discovery setting Previously, pytest config extraction (python_functions, python_classes, describe_prefixes) only happened when pytest_discovery was enabled. This caused Neotest to use default patterns (^test for functions, ^Test for classes) instead of custom patterns defined in pytest.ini or pyproject.toml. The pytest_discovery flag controls parameterized test instance discovery via pytest, not pattern extraction for TreeSitter. These should be decoupled. Now pytest config is always extracted for pytest runners, allowing TreeSitter queries to discover tests using custom naming patterns (e.g., should_*, A_*) even when pytest_discovery is disabled (the default). --- lua/neotest-python/base.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lua/neotest-python/base.lua b/lua/neotest-python/base.lua index 68ffa50..433da9f 100644 --- a/lua/neotest-python/base.lua +++ b/lua/neotest-python/base.lua @@ -113,7 +113,9 @@ local function scan_pytest_config(runner, config, python_command) local namespace_pattern = "" -- For describe_prefixes local class_pattern = "" -- For python_classes - if runner == "pytest" and config.pytest_discovery then + -- 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(), From cec1f50a91f303166d3028af8d74e8256326b749 Mon Sep 17 00:00:00 2001 From: Salvador Ruiz Date: Sat, 18 Apr 2026 11:37:38 +0200 Subject: [PATCH 10/10] fix: read pytest config from project root and fix treesitter queries - Pass project root through discover_positions -> treesitter_queries -> scan_pytest_config -> subprocess argv so the Python script always reads the correct pyproject.toml regardless of Neovim cwd. - Replace pytest.main() in extract_test_name_template with direct file parsing (tomllib/tomli for pyproject.toml, configparser for pytest.ini / setup.cfg / tox.ini). The old approach triggered full pytest collection on large projects, hanging Neovim indefinitely. - Wrap config.getini('describe_prefixes') in try/except ValueError so the subprocess does not crash silently when pytest-describe is not installed. - Fix fallback test_function_pattern from '^test' to '^test_|^it_' to match the documented default of 'test_* it_*' and avoid matching unrelated names like 'testutils'. - Add missing treesitter query for decorated namespace/container functions (e.g. @decorator + def describe_math():) which were not captured as namespaces despite decorated test functions and classes being handled. --- .gitignore | 1 + lua/neotest-python/adapter.lua | 2 +- lua/neotest-python/base.lua | 19 +++++-- neotest_python/pytest.py | 95 +++++++++++++++++++++++++++++++--- 4 files changed, 106 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 2c69548..5aba782 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,4 @@ dmypy.json Session.vim +tmp/ diff --git a/lua/neotest-python/adapter.lua b/lua/neotest-python/adapter.lua index d0b5999..507ae3f 100644 --- a/lua/neotest-python/adapter.lua +++ b/lua/neotest-python/adapter.lua @@ -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", }) diff --git a/lua/neotest-python/base.lua b/lua/neotest-python/base.lua index 433da9f..62832f5 100644 --- a/lua/neotest-python/base.lua +++ b/lua/neotest-python/base.lua @@ -107,9 +107,10 @@ end ---@param python_command string[] ---@param config neotest-python._AdapterConfig ---@param runner string +---@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) - local test_function_pattern = "^test" +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 @@ -120,6 +121,7 @@ local function scan_pytest_config(runner, config, python_command) python_command, M.get_script_path(), "--pytest-extract-test-name-template", + root or "", }) local _, data = lib.process.run(cmd, { stdout = true, stderr = true }) @@ -201,9 +203,10 @@ 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 patterns = scan_pytest_config(runner, config, python_command) +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 @@ -217,6 +220,13 @@ M.treesitter_queries = function(runner, config, python_command) (#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) @@ -243,6 +253,7 @@ M.treesitter_queries = function(runner, config, python_command) (#match? @namespace.name "%s"))) @namespace.definition ]], + namespace_pattern, namespace_pattern, test_function_pattern, test_function_pattern, diff --git a/neotest_python/pytest.py b/neotest_python/pytest.py index 83c8661..00b5bb5 100644 --- a/neotest_python/pytest.py +++ b/neotest_python/pytest.py @@ -245,9 +245,12 @@ def pytest_collection_modifyitems(config): } # Extract describe_prefixes if pytest-describe is configured - describe_prefixes = config.getini("describe_prefixes") - if describe_prefixes: - extracted_config["describe_prefixes"] = " ".join(describe_prefixes) + try: + describe_prefixes = config.getini("describe_prefixes") + if describe_prefixes: + extracted_config["describe_prefixes"] = " ".join(describe_prefixes) + except ValueError: + pass # pytest-describe not installed; use Lua defaults # Extract python_classes if configured python_classes = config.getini("python_classes") @@ -257,10 +260,90 @@ def pytest_collection_modifyitems(config): print(f"\n{json.dumps(extracted_config)}\n") +def _read_pytest_config_from_files(root: str) -> dict: + """Read pytest ini options directly from config files without running pytest. + + Supports pyproject.toml, pytest.ini, setup.cfg, and tox.ini. + This avoids triggering pytest collection on large projects. + """ + from pathlib import Path + import sys + import configparser + + root_path = Path(root) + result = {} + + # --- pyproject.toml --- + pyproject = root_path / "pyproject.toml" + if pyproject.exists(): + try: + if sys.version_info >= (3, 11): + import tomllib + + with open(pyproject, "rb") as f: + data = tomllib.load(f) + else: + import tomli # type: ignore[import] + + with open(pyproject, "rb") as f: + data = tomli.load(f) + opts = data.get("tool", {}).get("pytest", {}).get("ini_options", {}) + for key in ("python_functions", "python_classes", "describe_prefixes"): + if key in opts: + val = opts[key] + result[key] = " ".join(val) if isinstance(val, list) else str(val) + except Exception: + pass + + if result: + return result + + # --- pytest.ini --- + pytest_ini = root_path / "pytest.ini" + if pytest_ini.exists(): + cfg = configparser.ConfigParser() + cfg.read(pytest_ini) + if cfg.has_section("pytest"): + for key in ("python_functions", "python_classes", "describe_prefixes"): + if cfg.has_option("pytest", key): + result[key] = cfg.get("pytest", key).strip() + + if result: + return result + + # --- setup.cfg --- + setup_cfg = root_path / "setup.cfg" + if setup_cfg.exists(): + cfg = configparser.ConfigParser() + cfg.read(setup_cfg) + if cfg.has_section("tool:pytest"): + for key in ("python_functions", "python_classes", "describe_prefixes"): + if cfg.has_option("tool:pytest", key): + result[key] = cfg.get("tool:pytest", key).strip() + + if result: + return result + + # --- tox.ini --- + tox_ini = root_path / "tox.ini" + if tox_ini.exists(): + cfg = configparser.ConfigParser() + cfg.read(tox_ini) + if cfg.has_section("pytest"): + for key in ("python_functions", "python_classes", "describe_prefixes"): + if cfg.has_option("pytest", key): + result[key] = cfg.get("pytest", key).strip() + + return result + + def extract_test_name_template(args) -> int: - return int( - pytest.main(args=["-k", "neotest_none"], plugins=[TestNameTemplateExtractor]) - ) + # args[0] is the project root directory passed from Lua. + # Parse config files directly to avoid triggering pytest collection on large projects. + root = args[0] if args else "." + config = _read_pytest_config_from_files(root) + print(f"\n{json.dumps(config)}\n") + return 0 def collect(args) -> int: