diff --git a/README.md b/README.md index 74dd888..c94039a 100644 --- a/README.md +++ b/README.md @@ -14,35 +14,149 @@ require("neotest").setup({ ``` You can optionally supply configuration settings: + ```lua require("neotest").setup({ adapters = { require("neotest-python")({ - -- Extra arguments for nvim-dap configuration - -- See https://github.com/microsoft/debugpy/wiki/Debug-configuration-settings for values - dap = { justMyCode = false }, - -- Command line arguments for runner - -- Can also be a function to return dynamic values - args = {"--log-level", "DEBUG"}, - -- Runner to use. Will use pytest if available by default. - -- Can be a function to return dynamic value. - runner = "pytest", - -- Custom python path for the runner. - -- Can be a string or a list of strings. - -- Can also be a function to return dynamic value. - -- If not provided, the path will be inferred by checking for - -- virtual envs in the local directory and for Pipenev/Poetry configs - python = ".venv/bin/python", - -- Returns if a given file path is a test file. - -- NB: This function is called a lot so don't perform any heavy tasks within it. - is_test_file = function(file_path) - ... - end, - -- !!EXPERIMENTAL!! Enable shelling out to `pytest` to discover test - -- instances for files containing a parametrize mark (default: false) - pytest_discover_instances = true, - }) - } + -- Extra arguments for nvim-dap configuration + -- See https://github.com/microsoft/debugpy/wiki/Debug-configuration-settings for values + dap = { justMyCode = false }, + -- Command line arguments for runner + -- Can also be a function to return dynamic values + args = { "--log-level", "DEBUG" }, + -- Working directory for spawned test processes. + -- Can also be a function receiving (root, position). + cwd = vim.fn.getcwd(), + -- Extra environment variables for spawned test processes. + -- Can also be a function receiving (root, position). + env = { PYTHONPATH = vim.fn.getcwd() }, + -- Runner to use. Will use pytest if available by default. + -- Can be a function to return dynamic value. + runner = "pytest", + -- Custom python path for the runner. + -- Can be a string or a list of strings. + -- Can also be a function to return dynamic value. + -- If not provided, the path will be inferred by checking for + -- virtual envs in the local directory and for Pipenv/Poetry configs. + python = ".venv/bin/python", + -- Returns if a given file path is a test file. + -- NB: This function is called a lot so don't perform any heavy tasks within it. + is_test_file = function(file_path) + ... + end, + -- !!EXPERIMENTAL!! Enable shelling out to `pytest` to discover test + -- instances for files containing a parametrize mark (default: false) + pytest_discover_instances = true, + }), + }, }) +``` + +### Docker/Remote Integration + +For Docker or other remote execution, use `python` for the command and +`path_mappings` when remote paths differ from local paths. + +`neotest-python` writes result and stream files inside the mapped project root, +so a normal project mount is enough; no separate `/tmp` or plugin-directory +mount is required. + +```lua +require("neotest").setup({ + adapters = { + require("neotest-python")({ + python = { "docker", "compose", "exec", "-T", "-w", "/app", "web", "python" }, + cwd = vim.fn.getcwd(), + env = { PYTHONPATH = "/app" }, + path_mappings = { + [vim.fn.getcwd()] = "/app", + }, + }), + }, +}) +``` + +### Docker Debugging + +For remote debugging, return an attach config from `dap`. Attach configs get +debugpy `pathMappings` from `path_mappings` unless you provide your own. +```lua +require("neotest-python")({ + python = { "docker", "compose", "exec", "-T", "-w", "/app", "web", "python" }, + path_mappings = { + [vim.fn.getcwd()] = "/app", + }, + dap = function(_, _, _, context) + return { + request = "attach", + connect = { host = "127.0.0.1", port = 5678 }, + before = function() + -- Start debugpy in the container here. + -- `context.remote_script_path` and `context.script_args` + -- already contain the translated paths for this test run. + end, + } + end, +}) +``` + +If your remote debug flow should not stop on pytest internal exceptions, set +`NEOTEST_PYTHON_DISABLE_POSTMORTEM=1` in the debuggee environment. + +`path_mappings` can also be a function, which is useful in monorepos: + +```lua +require("neotest-python")({ + python = function(root) + if root:match("services/api") then + return { "docker", "exec", "-T", "api-container", "python" } + end + return { "python" } + end, + path_mappings = function(root) + if root:match("services/api") then + return { [root] = "/app" } + end + return {} + end, +}) +``` + +`python`, `path_mappings`, and `env` are called on every `discover_positions` +(i.e. on most buffer parses of a test file, not just on run), so cache +anything non-trivial keyed by `root` rather than recomputing it per call. This +matters in particular if you derive the command from a `docker-compose.yml` +instead of hardcoding a service name: + +```lua +local compose_cache = {} +local function compose_content(root) + if compose_cache[root] == nil then + local handle = io.open(root .. "/compose.yaml", "r") + compose_cache[root] = handle and handle:read("*a") or false + if handle then + handle:close() + end + end + return compose_cache[root] or nil +end + +-- e.g. a monorepo where each service's compose entry is named after its +-- directory: derive the service instead of maintaining a lookup table that +-- has to be updated by hand every time a service is added. +require("neotest-python")({ + python = function(root) + local service = root:match("([^/]+)$") + local content = compose_content(root) + if content and content:find("\n " .. service .. ":", 1, true) then + return { "docker", "compose", "exec", "-T", "-w", "/app", service, "python" } + end + return { "python" } + end, + path_mappings = function(root) + return { [root] = "/app" } + end, +}) ``` diff --git a/lua/neotest-python/adapter.lua b/lua/neotest-python/adapter.lua index d0b5999..87b26e0 100644 --- a/lua/neotest-python/adapter.lua +++ b/lua/neotest-python/adapter.lua @@ -1,15 +1,19 @@ -local nio = require("nio") local lib = require("neotest.lib") local pytest = require("neotest-python.pytest") local base = require("neotest-python.base") +local path_mapping = require("neotest-python.path_mapping") ---@class neotest-python._AdapterConfig ----@field dap_args? table +---@field dap_args? table|fun(root: string, position: neotest.Position, default_config: table, context: table): table ---@field pytest_discovery? boolean ---@field is_test_file fun(file_path: string):boolean ---@field get_python_command fun(root: string):string[] ---@field get_args fun(runner: string, position: neotest.Position, strategy: string): string[] +---@field get_cwd fun(root: string, position: neotest.Position): string|nil +---@field get_env fun(root: string, position: neotest.Position): table ---@field get_runner fun(python_command: string[]): string +---@field get_path_mappings fun(root: string): table +---@field root fun(path: string): string|nil ---@param config neotest-python._AdapterConfig ---@return neotest.Adapter @@ -18,8 +22,9 @@ return function(config) ---@param results_path string ---@param stream_path string ---@param runner string + ---@param mappings table ---@return string[] - local function build_script_args(run_args, results_path, stream_path, runner) + local function build_script_args(run_args, results_path, stream_path, runner, mappings) local script_args = { "--results-file", results_path, @@ -44,33 +49,75 @@ return function(config) end if position then - table.insert(script_args, position.id) + table.insert(script_args, path_mapping.to_remote(position.id, mappings)) end return script_args end + local function to_host_results(results, mappings) + local host_results = {} + for id, pos_result in pairs(results) do + local host_id = path_mapping.to_host(id, mappings) + if pos_result and pos_result.output_path then + pos_result.output_path = path_mapping.to_host(pos_result.output_path, mappings) + end + host_results[host_id] = pos_result + end + return host_results + end + + local function get_script_paths(root, mappings) + local script_path = vim.fn.resolve(base.get_script_path()) + local remote_script_path = path_mapping.to_remote(script_path, mappings) + local runtime_dir + + if remote_script_path == script_path and path_mapping.to_remote(root, mappings) ~= root then + script_path, runtime_dir = base.copy_runtime(root) + remote_script_path = path_mapping.to_remote(script_path, mappings) + end + + return script_path, remote_script_path, runtime_dir + end + ---@type neotest.Adapter return { name = "neotest-python", - root = base.get_root, + root = config.root, filter_dir = function(name) return name ~= "venv" end, is_test_file = config.is_test_file, discover_positions = function(path) - local root = base.get_root(path) or vim.loop.cwd() or "" + path = vim.fn.resolve(path) + local root = config.root(path) or vim.loop.cwd() or "" local python_command = config.get_python_command(root) local runner = config.get_runner(python_command) + local mappings = path_mapping.normalize(config.get_path_mappings(root)) - local positions = lib.treesitter.parse_positions(path, base.treesitter_queries(runner, config, python_command), { - require_namespaces = runner == "unittest", - }) + local positions = lib.treesitter.parse_positions( + path, + base.treesitter_queries(runner, config, python_command), + { + require_namespaces = runner == "unittest", + } + ) if runner == "pytest" and config.pytest_discovery then - pytest.augment_positions(python_command, base.get_script_path(), path, positions, root) + local _, remote_script_path, runtime_dir = get_script_paths(root, mappings) + pytest.augment_positions( + python_command, + remote_script_path, + path_mapping.to_remote(path, mappings), + positions, + path_mapping.to_remote(root, mappings), + mappings + ) + if runtime_dir then + base.remove_dir(runtime_dir) + end end return positions @@ -79,32 +126,72 @@ return function(config) ---@return neotest.RunSpec build_spec = function(args) local position = args.tree:data() + position.path = vim.fn.resolve(position.path) - local root = base.get_root(position.path) or vim.loop.cwd() or "" + local root = config.root(position.path) or vim.loop.cwd() or "" local python_command = config.get_python_command(root) local runner = config.get_runner(python_command) + local mappings = path_mapping.normalize(config.get_path_mappings(root)) + local cwd = config.get_cwd(root, position) + local env = config.get_env(root, position) or {} + if vim.tbl_isempty(env) then + env = nil + end - local results_path = nio.fn.tempname() - local stream_path = nio.fn.tempname() + local results_path = path_mapping.tempname(root, mappings) + local stream_path = path_mapping.tempname(root, mappings) lib.files.write(stream_path, "") local stream_data, stop_stream = lib.files.stream_lines(stream_path) - local script_args = build_script_args(args, results_path, stream_path, runner) - local script_path = base.get_script_path() + local remote_results_path = path_mapping.to_remote(results_path, mappings) + local remote_stream_path = path_mapping.to_remote(stream_path, mappings) + + local script_args = + build_script_args(args, remote_results_path, remote_stream_path, runner, mappings) + local script_path, remote_script_path, runtime_dir = get_script_paths(root, mappings) + local command = + vim.iter({ python_command, remote_script_path, script_args }):flatten():totable() local strategy_config if args.strategy == "dap" then - strategy_config = - base.create_dap_config(python_command, script_path, script_args, config.dap_args) + strategy_config = base.create_dap_config( + python_command, + script_path, + script_args, + cwd, + env, + config.dap_args, + { + root = root, + position = position, + mappings = mappings, + command = command, + python_command = python_command, + script_path = script_path, + remote_script_path = remote_script_path, + script_args = script_args, + results_path = results_path, + stream_path = stream_path, + remote_results_path = remote_results_path, + remote_stream_path = remote_stream_path, + runtime_dir = runtime_dir, + cwd = cwd, + env = env, + } + ) end + ---@type neotest.RunSpec return { - command = vim.iter({ python_command, script_path, script_args }):flatten():totable(), + command = command, context = { results_path = results_path, + stream_path = stream_path, + runtime_dir = runtime_dir, stop_stream = stop_stream, + mappings = mappings, }, stream = function() return function() @@ -114,10 +201,12 @@ return function(config) local result = vim.json.decode(line, { luanil = { object = true } }) results[result.id] = result.result end - return results + return to_host_results(results, mappings) end end, strategy = strategy_config, + cwd = cwd, + env = env, } end, ---@param spec neotest.RunSpec @@ -130,10 +219,16 @@ return function(config) data = "{}" end local results = vim.json.decode(data, { luanil = { object = true } }) - for _, pos_result in pairs(results) do + local host_results = to_host_results(results, spec.context.mappings) + for _, pos_result in pairs(host_results) do result.output_path = pos_result.output_path end - return results + pcall(vim.loop.fs_unlink, spec.context.results_path) + pcall(vim.loop.fs_unlink, spec.context.stream_path) + if spec.context.runtime_dir then + base.remove_dir(spec.context.runtime_dir) + end + return host_results end, } end diff --git a/lua/neotest-python/base.lua b/lua/neotest-python/base.lua index 6d4a3d3..b98fa42 100644 --- a/lua/neotest-python/base.lua +++ b/lua/neotest-python/base.lua @@ -1,8 +1,58 @@ local nio = require("nio") local lib = require("neotest.lib") local Path = require("plenary.path") +local path_mapping = require("neotest-python.path_mapping") local M = {} +local script_path_mem + +local function copy_file(source, target) + lib.files.write(target, lib.files.read(source)) +end + +local function copy_dir(source, target) + nio.fn.mkdir(target, "p") + + local scanner = vim.loop.fs_scandir(source) + while scanner do + local name, kind = vim.loop.fs_scandir_next(scanner) + if not name then + break + end + + local source_path = source .. lib.files.sep .. name + local target_path = target .. lib.files.sep .. name + if kind == "directory" and name ~= "__pycache__" then + copy_dir(source_path, target_path) + elseif kind == "file" and vim.endswith(name, ".py") then + copy_file(source_path, target_path) + end + end +end + +local function remove_dir(path) + local uv = vim.uv or vim.loop + local scanner = uv.fs_scandir(path) + if not scanner then + return + end + + while true do + local name, kind = uv.fs_scandir_next(scanner) + if not name then + break + end + + local child_path = path .. lib.files.sep .. name + if kind == "directory" then + remove_dir(child_path) + else + pcall(uv.fs_unlink, child_path) + end + end + + pcall(uv.fs_rmdir, path) +end function M.is_test_file(file_path) if not vim.endswith(file_path, ".py") then @@ -94,16 +144,45 @@ end ---@return string function M.get_script_path() + if script_path_mem then + return script_path_mem + end + local paths = vim.api.nvim_get_runtime_file("neotest.py", true) for _, path in ipairs(paths) do + path = vim.fn.fnamemodify(path, ":p") if vim.endswith(path, ("neotest-python%sneotest.py"):format(lib.files.sep)) then - return path + script_path_mem = path + return script_path_mem end end error("neotest.py not found") end +---@param root string +---@return string script_path +---@return string runtime_dir +function M.copy_runtime(root) + local runtime_source = Path:new(M.get_script_path()):parent().filename + local runtime_dir = + Path:new(root, ".neotest-python-" .. nio.fn.tempname():match("[^/]+$")).filename + + nio.fn.mkdir(runtime_dir, "p") + copy_file( + Path:new(runtime_source, "neotest.py").filename, + Path:new(runtime_dir, "neotest.py").filename + ) + copy_dir( + Path:new(runtime_source, "neotest_python").filename, + Path:new(runtime_dir, "neotest_python").filename + ) + + return Path:new(runtime_dir, "neotest.py").filename, runtime_dir +end + +M.remove_dir = remove_dir + ---@param python_command string[] ---@param config neotest-python._AdapterConfig ---@param runner string @@ -111,7 +190,11 @@ 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 @@ -130,7 +213,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,22 +242,47 @@ 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 = 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", { +function M.create_dap_config(python_path, script_path, script_args, cwd, env, dap_args, context) + local default_config = { type = "python", name = "Neotest Debugger", request = "launch", python = python_path, program = script_path, - cwd = nio.fn.getcwd(), + cwd = cwd or nio.fn.getcwd(), + env = env, args = script_args, - }, dap_args or {}) + } + + local dap_config = default_config + if type(dap_args) == "function" then + local override = dap_args(context.root, context.position, vim.deepcopy(default_config), context) + if override then + dap_config = vim.tbl_deep_extend("force", default_config, override) + end + elseif dap_args then + dap_config = vim.tbl_deep_extend("force", default_config, dap_args) + end + + if dap_config.request == "attach" then + dap_config.python = nil + dap_config.program = nil + dap_config.args = nil + if not dap_config.pathMappings and context.mappings then + dap_config.pathMappings = path_mapping.to_dap_path_mappings(context.mappings) + end + end + + return dap_config end local stored_runners = {} diff --git a/lua/neotest-python/init.lua b/lua/neotest-python/init.lua index 73cfc6d..bca739a 100644 --- a/lua/neotest-python/init.lua +++ b/lua/neotest-python/init.lua @@ -2,12 +2,16 @@ local base = require("neotest-python.base") local create_adapter = require("neotest-python.adapter") ---@class neotest-python.AdapterConfig ----@field dap? table +---@field dap? table|fun(root: string, position: neotest.Position, default_config: table, context: table): table ---@field pytest_discover_instances? boolean ---@field is_test_file? fun(file_path: string):boolean ---@field python? string|string[]|fun(root: string):string[] ---@field args? string[]|fun(runner: string, position: neotest.Position, strategy: string): string[] +---@field cwd? string|fun(root: string, position: neotest.Position): string +---@field env? table|fun(root: string, position: neotest.Position): table ---@field runner? string|fun(python_command: string[]): string +---@field path_mappings? table|fun(root: string): table +---@field root? fun(path: string): string|nil local is_callable = function(obj) return type(obj) == "function" or (type(obj) == "table" and obj.__call) @@ -31,7 +35,7 @@ local augment_config = function(config) return python end - return base.get_python(root) + return base.get_python_command(root) end end @@ -47,6 +51,28 @@ local augment_config = function(config) end end + local get_cwd = function() + return nil + end + if is_callable(config.cwd) then + get_cwd = config.cwd + elseif config.cwd then + get_cwd = function() + return config.cwd + end + end + + local get_env = function() + return {} + end + if is_callable(config.env) then + get_env = config.env + elseif config.env then + get_env = function() + return config.env + end + end + local get_runner = base.get_runner if is_callable(config.runner) then get_runner = config.runner @@ -56,14 +82,29 @@ local augment_config = function(config) end end + local get_path_mappings = function() + return {} + end + if is_callable(config.path_mappings) then + get_path_mappings = config.path_mappings + elseif config.path_mappings then + get_path_mappings = function() + return config.path_mappings + end + end + ---@type neotest-python._AdapterConfig return { pytest_discovery = config.pytest_discover_instances, dap_args = config.dap, get_runner = get_runner, get_args = get_args, + get_cwd = get_cwd, + get_env = get_env, is_test_file = config.is_test_file or base.is_test_file, get_python_command = get_python_command, + get_path_mappings = get_path_mappings, + root = config.root or base.get_root, } end diff --git a/lua/neotest-python/path_mapping.lua b/lua/neotest-python/path_mapping.lua new file mode 100644 index 0000000..bfae7a3 --- /dev/null +++ b/lua/neotest-python/path_mapping.lua @@ -0,0 +1,133 @@ +local nio = require("nio") + +local M = {} + +local function trim(path) + if type(path) ~= "string" or path == "" or path == "/" then + return path + end + + return path:gsub("/+$", "") +end + +local function resolve(path) + if type(path) ~= "string" or path == "" then + return path + end + + local ok, resolved = pcall(vim.fn.resolve, path) + if ok and resolved ~= "" then + path = resolved + end + + return trim(path) +end + +local function sorted_keys(paths) + local keys = {} + for path in pairs(paths) do + table.insert(keys, path) + end + table.sort(keys, function(a, b) + return #a > #b + end) + return keys +end + +---@param raw_mappings table|nil +---@return { forward: table, reverse: table, forward_keys: string[], reverse_keys: string[] } +function M.normalize(raw_mappings) + local mappings = { + forward = {}, + reverse = {}, + forward_keys = {}, + reverse_keys = {}, + } + + for host_path, remote_path in pairs(raw_mappings or {}) do + host_path = resolve(host_path) + remote_path = trim(remote_path) + if host_path and remote_path then + mappings.forward[host_path] = remote_path + mappings.reverse[remote_path] = host_path + end + end + + mappings.forward_keys = sorted_keys(mappings.forward) + mappings.reverse_keys = sorted_keys(mappings.reverse) + + return mappings +end + +local function translate(path, paths, keys) + if not path then + return path + end + + for _, from_path in ipairs(keys or {}) do + if path:sub(1, #from_path) == from_path then + local next_char = path:sub(#from_path + 1, #from_path + 1) + if next_char == "" or next_char == "/" then + local suffix = path:sub(#from_path + 1) + local mapped_path = trim(paths[from_path]) + if suffix == "" then + return mapped_path + end + if mapped_path == "/" then + return "/" .. suffix:gsub("^/", "") + end + return mapped_path .. suffix + end + end + end + + return path +end + +---@param path string +---@param mappings { forward: table, forward_keys: string[] } +---@return string +function M.to_remote(path, mappings) + if not mappings then + return path + end + return translate(path, mappings.forward, mappings.forward_keys) +end + +---@param path string +---@param mappings { reverse: table, reverse_keys: string[] } +---@return string +function M.to_host(path, mappings) + if not mappings then + return path + end + return translate(path, mappings.reverse, mappings.reverse_keys) +end + +---@param root string +---@param mappings { forward: table, forward_keys: string[] } +---@return string +function M.tempname(root, mappings) + root = resolve(root) + local path = nio.fn.tempname() + if root and M.to_remote(root, mappings) ~= root then + return root .. "/.neotest-python-" .. path:match("[^/]+$") + end + + return resolve(path) +end + +---@param mappings { forward: table, forward_keys: string[] } +---@return { localRoot: string, remoteRoot: string }[] +function M.to_dap_path_mappings(mappings) + local path_mappings = {} + for _, local_root in ipairs(mappings and mappings.forward_keys or {}) do + path_mappings[#path_mappings + 1] = { + localRoot = local_root, + remoteRoot = mappings.forward[local_root], + } + end + return path_mappings +end + +return M diff --git a/lua/neotest-python/pytest.lua b/lua/neotest-python/pytest.lua index b30469e..e243b2d 100644 --- a/lua/neotest-python/pytest.lua +++ b/lua/neotest-python/pytest.lua @@ -1,5 +1,6 @@ local lib = require("neotest.lib") local logger = require("neotest.logging") +local path_mapping = require("neotest-python.path_mapping") local M = {} @@ -52,7 +53,8 @@ end ---@param path string ---@param positions neotest.Tree ---@param root string -local function discover_params(python, script, path, positions, root) +---@param mappings table +local function discover_params(python, script, path, positions, root, mappings) local cmd = vim.iter({ python, script, "--pytest-collect", path }):flatten():totable() logger.debug("Running test instance discovery:", cmd) @@ -70,6 +72,7 @@ local function discover_params(python, script, path, positions, root) local param_index = string.find(line, "[", nil, true) if param_index then local test_id = root .. lib.files.path.sep .. string.sub(line, 1, param_index - 1) + test_id = path_mapping.to_host(test_id, mappings) local param_id = string.sub(line, param_index + 1, #line - 1) if positions:get_key(test_id) then @@ -91,9 +94,11 @@ end ---@param path string ---@param positions neotest.Tree ---@param root string -function M.augment_positions(python, script, path, positions, root) - if has_parametrize(path) then - local test_params = discover_params(python, script, path, positions, root) +---@param mappings table +function M.augment_positions(python, script, path, positions, root, mappings) + local host_path = path_mapping.to_host(path, mappings) + if has_parametrize(host_path) then + local test_params = discover_params(python, script, path, positions, root, mappings) add_test_instances(positions, test_params) end end diff --git a/neotest_python/django_unittest.py b/neotest_python/django_unittest.py index 714364b..71d2e39 100644 --- a/neotest_python/django_unittest.py +++ b/neotest_python/django_unittest.py @@ -12,7 +12,7 @@ from django import setup as django_setup from django.test.runner import DiscoverRunner -from .base import NeotestAdapter, NeotestError, NeotestResultStatus +from .base import NeotestAdapter, NeotestResultStatus class CaseUtilsMixin: diff --git a/neotest_python/pytest.py b/neotest_python/pytest.py index 57e3e27..03c0599 100644 --- a/neotest_python/pytest.py +++ b/neotest_python/pytest.py @@ -1,4 +1,5 @@ import json +import os import re from io import StringIO from pathlib import Path @@ -215,6 +216,9 @@ def maybe_debugpy_postmortem(excinfo): excinfo: A (type(e), e, e.__traceback__) tuple. See sys.exc_info() """ + if os.getenv("NEOTEST_PYTHON_DISABLE_POSTMORTEM") == "1": + return + # Reference: https://github.com/microsoft/debugpy/issues/723 import threading diff --git a/scripts/test b/scripts/test index 2f31a29..a6b5782 100755 --- a/scripts/test +++ b/scripts/test @@ -1,10 +1,23 @@ #!/bin/sh -PYTHON_DIR="rplugin/python3/ultest" +set -e +PYTHON_DIR="neotest_python" + +echo "Running Python tests..." +pytest_status=0 pytest \ --cov-branch \ --cov=${PYTHON_DIR} \ --cov-report xml:coverage/coverage.xml \ --cov-report term \ - --cov-report html:coverage + --cov-report html:coverage \ + || pytest_status=$? +if [ "$pytest_status" -eq 5 ]; then + echo "No Python tests collected." +elif [ "$pytest_status" -ne 0 ]; then + exit "$pytest_status" +fi + +printf "\nRunning Lua tests...\n" +nvim --headless -u NONE --cmd "set runtimepath+=." -c "luafile tests/run.lua" -c "qa!" diff --git a/tests/adapter_test.lua b/tests/adapter_test.lua new file mode 100644 index 0000000..b75da46 --- /dev/null +++ b/tests/adapter_test.lua @@ -0,0 +1,368 @@ +local function noop() end +local fake_positions + +package.loaded["nio"] = { + fn = { + glob = function() + return "" + end, + exepath = function(command) + return command + end, + getcwd = vim.fn.getcwd, + mkdir = vim.fn.mkdir, + tempname = vim.fn.tempname, + }, + run = function(fn) + fn() + end, +} + +package.loaded["neotest.logging"] = { + debug = noop, + warn = noop, +} + +package.loaded["neotest.lib"] = { + files = { + sep = "/", + path = { sep = "/" }, + exists = function() + return false + end, + read = function() + return "" + end, + write = noop, + stream_lines = function() + return function() + return {} + end, noop + end, + match_root_pattern = function() + return function() + return vim.fn.resolve(vim.fn.getcwd()) + end + end, + }, + process = { + run = function() + return 0, { stdout = "", stderr = "" } + end, + }, + func_util = { + index = function(items, value) + for index, item in ipairs(items) do + if item == value then + return index + end + end + end, + }, + treesitter = { + parse_positions = function() + return fake_positions + end, + }, +} + +local Path = { path = { sep = "/" } } +local path_methods = {} + +function path_methods:parent() + return Path:new(vim.fn.fnamemodify(self.filename, ":h")) +end + +function Path:new(...) + local parts = {} + for _, part in ipairs({ ... }) do + if part ~= "" then + table.insert(parts, tostring(part)) + end + end + return setmetatable({ filename = table.concat(parts, "/"):gsub("//+", "/") }, { + __index = path_methods, + __div = function(path, child) + return Path:new(path.filename, child) + end, + }) +end + +package.loaded["plenary.path"] = Path + +local nio = require("nio") +local base = require("neotest-python.base") +local neotest_python = require("neotest-python") +local pytest = require("neotest-python.pytest") + +local function fail(message) + error(message, 0) +end + +local function assert_equal(actual, expected, label) + if actual ~= expected then + fail( + string.format( + "%s\nexpected: %s\nactual: %s", + label, + vim.inspect(expected), + vim.inspect(actual) + ) + ) + end +end + +local function assert_matches(actual, pattern, label) + if not actual:match(pattern) then + fail(string.format("%s\nexpected pattern: %s\nactual: %s", label, pattern, actual)) + end +end + +local function assert_contains_sequence(items, sequence, label) + for index = 1, #items - #sequence + 1 do + local found = true + for offset = 1, #sequence do + if items[index + offset - 1] ~= sequence[offset] then + found = false + break + end + end + if found then + return + end + end + + fail( + string.format( + "%s\nexpected sequence: %s\nactual: %s", + label, + vim.inspect(sequence), + vim.inspect(items) + ) + ) +end + +local function make_tree(position) + return { + data = function() + return position + end, + } +end + +local function make_positions_tree(positions) + return { + iter_nodes = function() + local index = 0 + return function() + index = index + 1 + local node_position = positions[index] + if not node_position then + return + end + return index, make_tree(node_position) + end + end, + } +end + +local function find_arg(command, key) + for index, item in ipairs(command) do + if item == key then + return command[index + 1] + end + end +end + +local function find_path_mapping(path_mappings, local_root) + for _, mapping in ipairs(path_mappings or {}) do + if mapping.localRoot == local_root then + return mapping + end + end +end + +local root = vim.fn.resolve(vim.fn.getcwd()) +local position = { + id = root .. "/tests/example_test.py::test_demo", + path = root .. "/tests/example_test.py", +} + +local adapter = neotest_python({ + runner = "pytest", + python = { "python" }, + args = { "-n", "auto", "-q" }, + cwd = function(resolved_root) + return resolved_root + end, + env = function(_, current_position) + return { TEST_POSITION = current_position.id } + end, + path_mappings = { [root] = "/workspace" }, + root = function() + return root + end, +}) + +local attach_adapter = neotest_python({ + runner = "pytest", + python = function() + return nil + end, + args = { "-q" }, + path_mappings = { [root] = "/workspace" }, + dap = function(_, current_position, _, context) + return { + request = "attach", + connect = { host = "127.0.0.1", port = 5678 }, + position_id = current_position.id, + remote_script = context.remote_script_path, + } + end, + root = function() + return root + end, +}) + +nio.run(function() + local run_spec = adapter.build_spec({ tree = make_tree(vim.deepcopy(position)) }) + + assert_equal(run_spec.cwd, root, "build_spec should expose configured cwd") + assert_equal(run_spec.env.TEST_POSITION, position.id, "env callback should receive the position") + assert_contains_sequence( + run_spec.command, + { "--", "-n", "auto", "-q" }, + "build_spec should preserve pytest arguments" + ) + assert_matches( + find_arg(run_spec.command, "--results-file"), + "^/workspace/%.neotest%-python%-", + "results file should translate to a mapped project temp path" + ) + assert_matches( + find_arg(run_spec.command, "--stream-file"), + "^/workspace/%.neotest%-python%-", + "stream file should translate to a mapped project temp path" + ) + assert_matches( + run_spec.command[#run_spec.command], + "^/workspace/tests/example_test%.py::test_demo$", + "position id should translate to the remote path" + ) + + local dap_spec = attach_adapter.build_spec({ + tree = make_tree(vim.deepcopy(position)), + strategy = "dap", + }) + + assert_equal(dap_spec.command[1] ~= nil, true, "python fallback should use the default command") + assert_equal(dap_spec.strategy.type, "python", "dap overrides should keep default fields") + assert_equal(dap_spec.strategy.request, "attach", "dap config should allow attach") + assert_equal(dap_spec.strategy.connect.port, 5678, "dap config should preserve connect settings") + assert_equal( + dap_spec.strategy.position_id, + position.id, + "dap callback should receive the position" + ) + assert_equal( + dap_spec.strategy.remote_script, + "/workspace/neotest.py", + "dap context should expose remote script" + ) + assert_equal(dap_spec.strategy.program, nil, "attach config should drop launch-only program") + assert_equal(dap_spec.strategy.args, nil, "attach config should drop launch-only args") + + local project_mapping = find_path_mapping(dap_spec.strategy.pathMappings, root) + assert_equal(project_mapping.localRoot, root, "attach config should derive local debug mapping") + assert_equal( + project_mapping.remoteRoot, + "/workspace", + "attach config should derive remote debug mapping" + ) + + local discovery_root = root .. "/tests" + local discovery_path = discovery_root .. "/example_test.py" + fake_positions = make_positions_tree({ + { + id = discovery_path .. "::test_demo", + path = discovery_path, + type = "test", + }, + }) + + local copied_runtime_dir + local removed_runtime_dir + local discovery_script + local discovery_remote_path + local discovery_remote_root + local original_copy_runtime = base.copy_runtime + local original_remove_dir = base.remove_dir + local original_augment_positions = pytest.augment_positions + + base.copy_runtime = function(runtime_root) + local script_path, runtime_dir = original_copy_runtime(runtime_root) + copied_runtime_dir = runtime_dir + return script_path, runtime_dir + end + base.remove_dir = function(runtime_dir) + removed_runtime_dir = runtime_dir + return original_remove_dir(runtime_dir) + end + pytest.augment_positions = function(_, script, path, _, remote_root) + discovery_script = script + discovery_remote_path = path + discovery_remote_root = remote_root + end + + local discovery_adapter = neotest_python({ + runner = "pytest", + python = { "python" }, + path_mappings = { [discovery_root] = "/workspace" }, + pytest_discover_instances = true, + root = function() + return discovery_root + end, + }) + discovery_adapter.discover_positions(discovery_path) + + base.copy_runtime = original_copy_runtime + base.remove_dir = original_remove_dir + pytest.augment_positions = original_augment_positions + + assert_equal( + discovery_script:match("^/workspace/%.neotest%-python%-[^/]+/neotest%.py$") ~= nil, + true, + "pytest discovery should use a copied remote script when the plugin path is unmapped" + ) + assert_equal( + discovery_remote_path, + "/workspace/example_test.py", + "pytest discovery should pass the mapped remote test path" + ) + assert_equal( + discovery_remote_root, + "/workspace", + "pytest discovery should pass the mapped remote root" + ) + assert_equal( + removed_runtime_dir, + copied_runtime_dir, + "pytest discovery should clean the copied runtime directory" + ) + assert_equal( + (vim.uv or vim.loop).fs_stat(copied_runtime_dir) == nil, + true, + "pytest discovery runtime directory should not be left on disk" + ) + + vim.g.neotest_python_adapter_tests_passed = true + print("adapter tests passed") +end) + +if + not vim.wait(1000, function() + return vim.g.neotest_python_adapter_tests_passed == true + end) +then + fail("adapter tests timed out") +end diff --git a/tests/path_mapping_test.lua b/tests/path_mapping_test.lua new file mode 100644 index 0000000..4ad53d7 --- /dev/null +++ b/tests/path_mapping_test.lua @@ -0,0 +1,61 @@ +package.loaded["nio"] = { + fn = { + tempname = vim.fn.tempname, + }, +} + +local path_mapping = require("neotest-python.path_mapping") + +local function fail(message) + error(message, 0) +end + +local function assert_equal(actual, expected, label) + if actual ~= expected then + fail(string.format("%s\nexpected: %s\nactual: %s", label, expected, actual)) + end +end + +local function join_path(root, suffix) + if root:sub(-1) == "/" then + return root .. suffix + end + return root .. "/" .. suffix +end + +local cwd = vim.fn.resolve(vim.fn.getcwd()) + +local mappings = path_mapping.normalize({ + [cwd] = "/workspace", +}) + +assert_equal( + path_mapping.to_remote(join_path(cwd, "lua/neotest-python/adapter.lua"), mappings), + "/workspace/lua/neotest-python/adapter.lua", + "project paths should translate to the remote root" +) + +assert_equal( + path_mapping.to_host("/workspace/lua/neotest-python/adapter.lua", mappings), + join_path(cwd, "lua/neotest-python/adapter.lua"), + "remote paths should translate back to the host root" +) + +assert_equal( + path_mapping.to_remote( + join_path(cwd, "lua/neotest-python/adapter.lua::TestAdapter::test_build_spec"), + mappings + ), + "/workspace/lua/neotest-python/adapter.lua::TestAdapter::test_build_spec", + "test node ids should preserve their suffix when translated" +) + +assert_equal( + path_mapping + .to_remote(path_mapping.tempname(cwd, mappings), mappings) + :match("^/workspace/%.neotest%-python%-") ~= nil, + true, + "mapped temp files should live under the remote project root" +) + +print("path_mapping tests passed") diff --git a/tests/run.lua b/tests/run.lua new file mode 100644 index 0000000..2f22030 --- /dev/null +++ b/tests/run.lua @@ -0,0 +1,4 @@ +local root = vim.fn.getcwd() + +dofile(root .. "/tests/path_mapping_test.lua") +dofile(root .. "/tests/adapter_test.lua")