diff --git a/README.md b/README.md index 74dd888..ecfa0c6 100644 --- a/README.md +++ b/README.md @@ -18,31 +18,187 @@ You can optionally supply configuration settings: 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 }, + -- `dap` can also be a function: + -- function(root, position, default_config, context) -> table + -- Return partial overrides; they are merged with `default_config`. + -- `context` includes the resolved host/container paths, command, cwd and env. + -- 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, + -- Bi-directional path mapping for Docker/Remote integration. + -- Can be a table or a function to return dynamic values. + path_mappings = { + ["/host/project/path"] = "/container/project/path", + ["/tmp"] = "/tmp", + }, + }), + }, +}) +``` + +### Pytest-xdist + +`neotest-python` does not require Docker and does not manage worker counts +itself. If you use `pytest-xdist`, just pass the usual pytest flags through +`args`: + +```lua +require("neotest-python")({ + runner = "pytest", + args = { "-n", "auto", "--dist", "loadfile" }, +}) +``` + +Those arguments are forwarded unchanged to pytest, including when running +through Docker or other remote Python commands. + +### Docker/Remote Integration + +To run tests in a Docker container or any remote environment, use the `python` +and `path_mappings` options. `neotest-python` will translate host paths (where +Neovim runs) to container paths (where tests run) and back again. + +Example using `docker-compose`: + +```lua +require("neotest").setup({ + adapters = { + require("neotest-python")({ + -- Command to run python in the container + python = { "docker-compose", "exec", "-T", "web", "python" }, + cwd = vim.fn.getcwd(), + env = { + PYTHONPATH = "/app", + }, + + -- Map host paths to container paths + path_mappings = { + [vim.fn.getcwd()] = "/app", + -- Mount /tmp so host-container communication (results/streaming) works. + -- On macOS, `/tmp` mappings also cover the resolved `$TMPDIR` path. + ["/tmp"] = "/tmp", + }, + }), + }, +}) +``` + +### Docker Debugging + +For container debugging, return an attach config from `dap` and start the +debuggee in the callback using the provided `context`: + +```lua +require("neotest-python")({ + python = { "docker", "compose", "exec", "-T", "web", "python" }, + path_mappings = { + [vim.fn.getcwd()] = "/app", + ["/tmp"] = "/tmp", + }, + dap = function(_, _, _, context) + return { + request = "attach", + connect = { host = "127.0.0.1", port = 5678 }, + before = function() + -- Start debugpy in the container here. + -- `context.container_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. + +By making `path_mappings` a function, you can dynamically resolve mounts: + +```lua +path_mappings = function() + -- Logic to query docker inspect or docker-compose for volume mounts + return { + [vim.fn.getcwd()] = "/workspace", } +end +``` + +### Monorepo Support + +In monorepos where different subdirectories require different containers or +settings, you can either use a single dynamic adapter or multiple adapter +instances. + +#### Dynamic Configuration + +You can pass the `root` directory to both `python` and `path_mappings` to +dynamically determine the configuration: + +```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, }) +``` +#### Multiple Instances + +You can also override `root` detection to have multiple instances of the +adapter for different parts of your monorepo: + +```lua +require("neotest").setup({ + adapters = { + require("neotest-python")({ + root = function(path) + return path:match("services/api") and require("neotest-python.base").get_root(path) + end, + python = { "docker", "exec", "-T", "api-container", "python" }, + path_mappings = { ["services/api"] = "/app" }, + }), + require("neotest-python")({ + root = function(path) + return path:match("services/worker") and require("neotest-python.base").get_root(path) + end, + python = { "docker", "exec", "-T", "worker-container", "python" }, + path_mappings = { ["services/worker"] = "/app" }, + }), + }, +}) ``` diff --git a/lua/neotest-python/adapter.lua b/lua/neotest-python/adapter.lua index d0b5999..ded2219 100644 --- a/lua/neotest-python/adapter.lua +++ b/lua/neotest-python/adapter.lua @@ -2,14 +2,20 @@ 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") +local logger = require("neotest.logging") ---@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 +24,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,7 +51,9 @@ return function(config) end if position then - table.insert(script_args, position.id) + local container_id = path_mapping.to_container_path(position.id, mappings) + logger.debug("neotest-python: Position ID Host: ", position.id, " Container: ", container_id) + table.insert(script_args, container_id) end return script_args @@ -54,23 +63,28 @@ return function(config) 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_mappings(config.get_path_mappings(root)) 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 container_script_path = path_mapping.to_container_path(base.get_script_path(), mappings) + local container_path = path_mapping.to_container_path(path, mappings) + local container_root = path_mapping.to_container_path(root, mappings) + pytest.augment_positions(python_command, container_script_path, container_path, positions, container_root, mappings) end return positions @@ -79,32 +93,70 @@ 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_mappings(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 + + logger.debug("neotest-python: Root: ", root) + logger.debug("neotest-python: Mappings: ", mappings.forward) - local results_path = nio.fn.tempname() - local stream_path = nio.fn.tempname() + local results_path = vim.fn.resolve(nio.fn.tempname()) + local stream_path = vim.fn.resolve(nio.fn.tempname()) 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 container_results_path = path_mapping.to_container_path(results_path, mappings) + local container_stream_path = path_mapping.to_container_path(stream_path, mappings) + + logger.debug("neotest-python: Results Path Host: ", results_path, " Container: ", container_results_path) + logger.debug("neotest-python: Stream Path Host: ", stream_path, " Container: ", container_stream_path) + + local script_args = build_script_args(args, container_results_path, container_stream_path, runner, mappings) + local script_path = vim.fn.resolve(base.get_script_path()) + local container_script_path = path_mapping.to_container_path(script_path, mappings) + local command = vim.iter({ python_command, container_script_path, script_args }):flatten():totable() + + logger.debug("neotest-python: Script Path Host: ", script_path, " Container: ", container_script_path) 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, + container_script_path = container_script_path, + script_args = script_args, + results_path = results_path, + stream_path = stream_path, + container_results_path = container_results_path, + container_stream_path = container_stream_path, + cwd = cwd, + env = env, + }) end + + logger.debug("neotest-python: Full Command: ", table.concat(command, " ")) + ---@type neotest.RunSpec return { - command = vim.iter({ python_command, script_path, script_args }):flatten():totable(), + command = command, context = { results_path = results_path, stop_stream = stop_stream, + mappings = mappings, }, stream = function() return function() @@ -112,12 +164,18 @@ return function(config) local results = {} for _, line in ipairs(lines) do local result = vim.json.decode(line, { luanil = { object = true } }) - results[result.id] = result.result + local host_id = path_mapping.to_host_path(result.id, mappings) + if result.result and result.result.output_path then + result.result.output_path = path_mapping.to_host_path(result.result.output_path, mappings) + end + results[host_id] = result.result end return results end end, strategy = strategy_config, + cwd = cwd, + env = env, } end, ---@param spec neotest.RunSpec @@ -130,10 +188,18 @@ return function(config) data = "{}" end local results = vim.json.decode(data, { luanil = { object = true } }) - for _, pos_result in pairs(results) do + local host_results = {} + for id, pos_result in pairs(results) do + local host_id = path_mapping.to_host_path(id, spec.context.mappings) + if pos_result.output_path then + pos_result.output_path = path_mapping.to_host_path(pos_result.output_path, spec.context.mappings) + end + host_results[host_id] = pos_result + end + for _, pos_result in pairs(host_results) do result.output_path = pos_result.output_path end - return results + return host_results end, } end diff --git a/lua/neotest-python/base.lua b/lua/neotest-python/base.lua index 6d4a3d3..d96014d 100644 --- a/lua/neotest-python/base.lua +++ b/lua/neotest-python/base.lua @@ -3,6 +3,33 @@ local lib = require("neotest.lib") local Path = require("plenary.path") local M = {} +local script_path_mem + +---@param mappings { forward: table, forward_keys?: string[] }|table|nil +---@return { localRoot: string, remoteRoot: string }[] +function M.get_dap_path_mappings(mappings) + local forward = mappings and mappings.forward or mappings or {} + local keys = mappings and mappings.forward_keys or {} + local path_mappings = {} + + if vim.tbl_isempty(keys) then + for local_root in pairs(forward) do + table.insert(keys, local_root) + end + table.sort(keys, function(a, b) + return #a > #b + end) + end + + for _, local_root in ipairs(keys) do + path_mappings[#path_mappings + 1] = { + localRoot = local_root, + remoteRoot = forward[local_root], + } + end + + return path_mappings +end function M.is_test_file(file_path) if not vim.endswith(file_path, ".py") then @@ -94,10 +121,15 @@ 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 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 @@ -164,16 +196,38 @@ 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 = M.get_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..8712d51 --- /dev/null +++ b/lua/neotest-python/path_mapping.lua @@ -0,0 +1,239 @@ +local logger = require("neotest.logging") +local M = {} + +---@param path string|nil +---@return string|nil +local function trim_trailing_separators(path) + if type(path) ~= "string" or path == "" then + return path + end + if path == "/" then + return path + end + path = path:gsub("/+$", "") + return path == "" and "/" or path +end + +---@param path string|nil +---@return string|nil +local function resolve_host_path(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_trailing_separators(path) +end + +---@return string[] +local function get_temp_roots() + local uv = vim.uv or vim.loop + local candidates = {} + local seen = {} + + local function add(path) + local normalized = resolve_host_path(path) + if normalized and normalized ~= "" and not seen[normalized] then + seen[normalized] = true + table.insert(candidates, normalized) + end + end + + add(uv and uv.os_getenv and uv.os_getenv("TMPDIR") or nil) + add(uv and uv.os_tmpdir and uv.os_tmpdir() or nil) + add("/tmp") + + return candidates +end + +---@param host_path string +---@param temp_roots string[] +---@return boolean +local function is_temp_mapping(host_path, temp_roots) + local raw_path = trim_trailing_separators(host_path) + local resolved_path = resolve_host_path(host_path) + + if raw_path == "/tmp" then + return true + end + + for _, temp_root in ipairs(temp_roots) do + if raw_path == temp_root or resolved_path == temp_root then + return true + end + end + + return false +end + +---@param mappings table +---@return table +local function get_forward_mappings(mappings) + if mappings and mappings.forward then + return mappings.forward + end + return mappings or {} +end + +---@param mappings table +---@return table +local function get_reverse_mappings(mappings) + if mappings and mappings.reverse then + return mappings.reverse + end + + local inverse_mappings = {} + for host_path, container_path in pairs(mappings or {}) do + inverse_mappings[trim_trailing_separators(container_path)] = resolve_host_path(host_path) + end + return inverse_mappings +end + +---@param mappings table +---@return string[] +local function build_sorted_keys(mappings) + local keys = {} + for k in pairs(mappings) do + table.insert(keys, k) + end + table.sort(keys, function(a, b) + return #a > #b + end) + return keys +end + +---@param mappings table +---@return string[] +local function get_forward_keys(mappings) + if mappings and mappings.forward_keys then + return mappings.forward_keys + end + return build_sorted_keys(get_forward_mappings(mappings)) +end + +---@param mappings table +---@return string[] +local function get_reverse_keys(mappings) + if mappings and mappings.reverse_keys then + return mappings.reverse_keys + end + return build_sorted_keys(get_reverse_mappings(mappings)) +end + +---Normalize path mappings for host->container and container->host translation. +---Temp mappings are expanded so a simple `/tmp -> /tmp` config also matches +---macOS temp files created under the resolved `$TMPDIR`. +---@param raw_mappings table|nil +---@return { forward: table, reverse: table, forward_keys: string[], reverse_keys: string[] } +function M.normalize_mappings(raw_mappings) + raw_mappings = raw_mappings or {} + + local mappings = { + forward = {}, + reverse = {}, + forward_keys = {}, + reverse_keys = {}, + } + local temp_roots = get_temp_roots() + local preferred_temp_root = temp_roots[1] + + for host_path, container_path in pairs(raw_mappings) do + local resolved_host_path = resolve_host_path(host_path) + local normalized_container_path = trim_trailing_separators(container_path) + + if resolved_host_path and normalized_container_path then + mappings.forward[resolved_host_path] = normalized_container_path + + if is_temp_mapping(host_path, temp_roots) then + for _, temp_root in ipairs(temp_roots) do + mappings.forward[temp_root] = normalized_container_path + end + mappings.reverse[normalized_container_path] = preferred_temp_root or resolved_host_path + else + mappings.reverse[normalized_container_path] = resolved_host_path + end + end + end + + mappings.forward_keys = build_sorted_keys(mappings.forward) + mappings.reverse_keys = build_sorted_keys(mappings.reverse) + + return mappings +end + +---Translates a host file path to its corresponding path in the container. +---@param path string The host file path. +---@param mappings table Map of host paths to container paths. +---@return string The translated container path. +function M.to_container_path(path, mappings) + if not mappings or not path then + return path + end + local forward_mappings = get_forward_mappings(mappings) + local sorted_host_paths = get_forward_keys(mappings) + for _, host_path in ipairs(sorted_host_paths) do + local container_path = forward_mappings[host_path] + -- Use plain string matching for prefix to avoid regex escaping issues + if path:sub(1, #host_path) == host_path then + local next_char = path:sub(#host_path + 1, #host_path + 1) + -- Check if the match is at a path boundary (slash or end of string) + if next_char == "" or next_char == "/" or host_path:sub(-1) == "/" then + local suffix = path:sub(#host_path + 1) + -- Ensure exactly one slash between container_path and suffix if suffix is not empty + local result = container_path + if suffix ~= "" then + if suffix:sub(1, 1) ~= "/" and container_path:sub(-1) ~= "/" then + result = result .. "/" + end + result = result .. suffix + end + -- Clean up double slashes + result = result:gsub("//+", "/") + logger.debug("neotest-python: Translated Host Path: ", path, " to Container: ", result) + return result + end + end + end + logger.debug("neotest-python: No mapping found for host path: ", path) + return path +end + +---Translates a container file path back to its corresponding path on the host. +---@param path string The container file path. +---@param mappings table Map of host paths to container paths. +---@return string The translated host path. +function M.to_host_path(path, mappings) + if not mappings or not path then + return path + end + local inverse_mappings = get_reverse_mappings(mappings) + local sorted_container_paths = get_reverse_keys(mappings) + + for _, container_path in ipairs(sorted_container_paths) do + local host_path = inverse_mappings[container_path] + if path:sub(1, #container_path) == container_path then + local next_char = path:sub(#container_path + 1, #container_path + 1) + if next_char == "" or next_char == "/" or container_path:sub(-1) == "/" then + local suffix = path:sub(#container_path + 1) + local result = host_path + if suffix ~= "" then + if suffix:sub(1, 1) ~= "/" and host_path:sub(-1) ~= "/" then + result = result .. "/" + end + result = result .. suffix + end + result = result:gsub("//+", "/") + logger.debug("neotest-python: Translated Container Path: ", path, " to Host: ", result) + return result + end + end + end + logger.debug("neotest-python: No mapping found for container path: ", path) + return path +end + +return M diff --git a/lua/neotest-python/pytest.lua b/lua/neotest-python/pytest.lua index b30469e..52ad7d9 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_path(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(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/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..b7cb3dc 100755 --- a/scripts/test +++ b/scripts/test @@ -2,9 +2,13 @@ PYTHON_DIR="rplugin/python3/ultest" +echo "Running Python tests..." pytest \ --cov-branch \ --cov=${PYTHON_DIR} \ --cov-report xml:coverage/coverage.xml \ --cov-report term \ --cov-report html:coverage + +echo "\nRunning Lua tests..." +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..0f794ac --- /dev/null +++ b/tests/adapter_test.lua @@ -0,0 +1,243 @@ +local nio = require("nio") +local neotest_python = require("neotest-python") + +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_starts_with(actual, prefix, label) + if not vim.startswith(actual, prefix) then + fail(string.format("%s\nexpected prefix: %s\nactual: %s", label, prefix, actual)) + end +end + +local function assert_contains_sequence(items, sequence, label) + for index = 1, #items - #sequence + 1 do + local matches = true + for offset = 1, #sequence do + if items[index + offset - 1] ~= sequence[offset] then + matches = false + break + end + end + if matches then + return + end + end + + fail(string.format("%s\nexpected sequence: %s\nactual: %s", label, vim.inspect(sequence), vim.inspect(items))) +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 function make_tree(position) + return { + data = function() + return position + 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 = function() + return { "-n", "auto", "-q" } + end, + cwd = function(resolved_root) + return resolved_root + end, + env = function(_, current_position) + return { + TEST_ENV = "set", + TEST_POSITION = current_position.id, + } + end, + path_mappings = { + [root] = "/workspace", + ["/tmp"] = "/tmp", + }, + root = function() + return root + end, +}) + +local attach_adapter = neotest_python({ + runner = "pytest", + python = { "python" }, + args = function() + return { "-q" } + end, + path_mappings = { + [root] = "/workspace", + ["/tmp"] = "/tmp", + }, + dap = function(_, current_position, default_config, context) + return vim.tbl_extend("force", default_config, { + request = "attach", + connect = { + host = "127.0.0.1", + port = 5678, + }, + position_id = current_position.id, + container_script = context.container_script_path, + }) + end, + root = function() + return root + end, +}) + +local partial_attach_adapter = neotest_python({ + runner = "pytest", + python = function() + return nil + end, + args = function() + return { "-q" } + end, + path_mappings = { + [root] = "/workspace", + ["/tmp"] = "/tmp", + }, + dap = function() + return { + request = "attach", + connect = { + host = "127.0.0.1", + port = 9000, + }, + } + end, + root = function() + return root + 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 + +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_ENV, "set", "build_spec should expose configured env") + 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-xdist arguments" + ) + + assert_starts_with( + find_arg(run_spec.command, "--results-file"), + "/tmp/", + "results file should translate to the container temp path" + ) + assert_starts_with( + find_arg(run_spec.command, "--stream-file"), + "/tmp/", + "stream file should translate to the container temp path" + ) + assert_starts_with( + run_spec.command[#run_spec.command], + "/workspace/tests/example_test.py::test_demo", + "position id should translate to the container path" + ) + + local dap_spec = adapter.build_spec({ + tree = make_tree(vim.deepcopy(position)), + strategy = "dap", + }) + + assert_equal(dap_spec.strategy.cwd, root, "dap config should inherit configured cwd") + assert_equal(dap_spec.strategy.env.TEST_ENV, "set", "dap config should inherit configured env") + + local attach_dap_spec = attach_adapter.build_spec({ + tree = make_tree(vim.deepcopy(position)), + strategy = "dap", + }) + + assert_equal(attach_dap_spec.strategy.request, "attach", "dap config should allow overriding request type") + assert_equal(attach_dap_spec.strategy.connect.port, 5678, "attach config should preserve custom connect settings") + assert_equal( + attach_dap_spec.strategy.position_id, + position.id, + "dap callback should receive the current position" + ) + assert_equal( + attach_dap_spec.strategy.container_script, + "/workspace/neotest.py", + "dap callback should receive translated script path context" + ) + assert_equal(attach_dap_spec.strategy.program, nil, "attach config should drop launch-only program") + assert_equal(attach_dap_spec.strategy.args, nil, "attach config should drop launch-only args") + local project_mapping = find_path_mapping(attach_dap_spec.strategy.pathMappings, root) + assert_equal(project_mapping.localRoot, root, "attach config should derive debugpy path mappings from adapter mappings") + assert_equal(project_mapping.remoteRoot, "/workspace", "attach config should derive remote path mappings") + + local partial_attach_dap_spec = partial_attach_adapter.build_spec({ + tree = make_tree(vim.deepcopy(position)), + strategy = "dap", + }) + + assert_equal( + partial_attach_dap_spec.command[1] ~= nil, + true, + "python fallback should use the adapter default command when config.python returns nil" + ) + assert_equal( + partial_attach_dap_spec.strategy.type, + "python", + "partial dap overrides should keep the default debugger type" + ) + assert_equal( + partial_attach_dap_spec.strategy.name, + "Neotest Debugger", + "partial dap overrides should keep the default debugger name" + ) + assert_equal( + partial_attach_dap_spec.strategy.connect.port, + 9000, + "partial dap overrides should merge custom connect settings" + ) + assert_equal( + partial_attach_dap_spec.strategy.program, + nil, + "attach overrides should still drop launch-only program fields after merging" + ) + + 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..2ad6efd --- /dev/null +++ b/tests/path_mapping_test.lua @@ -0,0 +1,62 @@ +package.loaded["neotest.logging"] = { + debug = function() end, +} + +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 temp_root = vim.fn.resolve(vim.env.TMPDIR or (vim.uv or vim.loop).os_tmpdir() or "/tmp") + +local mappings = path_mapping.normalize_mappings({ + [cwd] = "/workspace", + ["/tmp"] = "/tmp", +}) + +assert_equal( + path_mapping.to_container_path(join_path(cwd, "lua/neotest-python/adapter.lua"), mappings), + "/workspace/lua/neotest-python/adapter.lua", + "project paths should translate to the container root" +) + +assert_equal( + path_mapping.to_container_path( + join_path(temp_root, "neotest-python/results.json"), + mappings + ), + "/tmp/neotest-python/results.json", + "resolved temp paths should translate via the /tmp mapping" +) + +assert_equal( + path_mapping.to_host_path("/tmp/neotest-python/results.json", mappings), + join_path(temp_root, "neotest-python/results.json"), + "container temp paths should translate back to the active host temp root" +) + +assert_equal( + path_mapping.to_container_path( + 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" +) + +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")