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 6d4a3d3..62832f5 100644 --- a/lua/neotest-python/base.lua +++ b/lua/neotest-python/base.lua @@ -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")) @@ -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 = 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..00b5bb5 100644 --- a/neotest_python/pytest.py +++ b/neotest_python/pytest.py @@ -240,14 +240,110 @@ 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": " ".join(config.getini("python_functions")) + } + + # Extract describe_prefixes if pytest-describe is configured + 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") + if python_classes: + extracted_config["python_classes"] = " ".join(python_classes) + + 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: