diff --git a/README.md b/README.md index 74dd888..c445c50 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,78 @@ require("neotest").setup({ -- !!EXPERIMENTAL!! Enable shelling out to `pytest` to discover test -- instances for files containing a parametrize mark (default: false) pytest_discover_instances = true, + -- Docker configuration for running tests in containers + docker = { + -- Docker container name or ID (required if using container-based execution) + container = "my-python-container", + -- OR use Docker image name instead of container + -- image = "python:3.9", + -- Custom docker command prefix (default: ["docker", "exec"]) + command = {"docker", "exec"}, + -- Additional docker arguments (e.g., interactive mode, working directory) + args = {"-i", "-w", "/app"}, + -- Working directory inside container (default: "/app") + workdir = "/app", + }, }) } }) ``` + +## Docker Support + +This adapter supports running tests inside Docker containers, which is useful for projects that are developed or deployed in containerized environments. + +### Configuration + +To enable Docker support, configure the `docker` option in your neotest setup: + +```lua +require("neotest").setup({ + adapters = { + require("neotest-python")({ + docker = { + container = "my-python-container", -- Required: container name or ID + args = {"-i", "-w", "/app"}, -- Optional: additional docker arguments + workdir = "/app", -- Optional: working directory (default: "/app") + } + }) + } +}) +``` + +### Docker Options + +- **`container`** (string): Name or ID of a running Docker container where tests will be executed +- **`image`** (string): Alternatively, specify a Docker image name to run tests in a new container +- **`command`** (table): Custom docker command prefix (default: `{"docker", "exec"}`) +- **`args`** (table): Additional arguments passed to the docker command (e.g., `{"-i", "-w", "/app"}`) +- **`workdir`** (string): Working directory inside the container (default: `"/app"`) + +### Requirements + +- Docker must be installed and accessible from your system +- The specified container must be running (when using `container` option) +- The container must have Python and your test dependencies installed +- Your project files must be mounted or copied into the container + +### Example Configurations + +#### Using an existing container: +```lua +docker = { + container = "web-app", + args = {"-i"}, + workdir = "/workspace" +} +``` + +#### Using a Docker image (creates temporary containers): +```lua +docker = { + image = "python:3.9-slim", + args = {"-v", "/host/project:/app", "-w", "/app"}, + workdir = "/app" +} +``` diff --git a/lua/neotest-python/adapter.lua b/lua/neotest-python/adapter.lua index d0b5999..6b9e6ab 100644 --- a/lua/neotest-python/adapter.lua +++ b/lua/neotest-python/adapter.lua @@ -10,6 +10,7 @@ local base = require("neotest-python.base") ---@field get_python_command fun(root: string):string[] ---@field get_args fun(runner: string, position: neotest.Position, strategy: string): string[] ---@field get_runner fun(python_command: string[]): string +---@field docker? neotest-python.DockerConfig ---@param config neotest-python._AdapterConfig ---@return neotest.Adapter @@ -18,8 +19,9 @@ return function(config) ---@param results_path string ---@param stream_path string ---@param runner string + ---@param docker_config neotest-python.DockerConfig? ---@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, docker_config) local script_args = { "--results-file", results_path, @@ -37,14 +39,30 @@ return function(config) table.insert(script_args, "--") - vim.list_extend(script_args, config.get_args(runner, position, run_args.strategy)) + if position then + vim.list_extend(script_args, config.get_args(runner, position, run_args.strategy)) + else + -- For full project runs, call get_args with nil position + vim.list_extend(script_args, config.get_args(runner, nil, run_args.strategy)) + end if run_args.extra_args then vim.list_extend(script_args, run_args.extra_args) end - if position then - table.insert(script_args, position.id) + if position and position.id then + local test_path = position.id + if docker_config then + test_path = base.translate_path_to_container(position.id, docker_config) + end + table.insert(script_args, test_path) + else + -- For full project runs, use the root directory + local root_path = vim.loop.cwd() + if docker_config then + root_path = base.translate_path_to_container(root_path, docker_config) + end + table.insert(script_args, root_path) end return script_args @@ -85,34 +103,69 @@ return function(config) local python_command = config.get_python_command(root) local runner = config.get_runner(python_command) - local results_path = nio.fn.tempname() - local stream_path = nio.fn.tempname() - lib.files.write(stream_path, "") + local results_path, stream_path, script_path, container_results_path, container_stream_path + if config.docker then + results_path = nio.fn.tempname() + stream_path = nio.fn.tempname() + script_path = base.copy_script_to_container(config.docker) + + local unique_id = tostring(math.random(1000000, 9999999)) + container_results_path = "/tmp/neotest_results_" .. unique_id + container_stream_path = "/tmp/neotest_stream_" .. unique_id + + lib.files.write(stream_path, "") + lib.files.write(results_path, "{}") + else + results_path = nio.fn.tempname() + stream_path = nio.fn.tempname() + script_path = base.get_script_path() + container_results_path = results_path + container_stream_path = stream_path + lib.files.write(stream_path, "") + end 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 script_args = build_script_args(args, container_results_path, container_stream_path, runner, config.docker) local strategy_config if args.strategy == "dap" then strategy_config = base.create_dap_config(python_command, script_path, script_args, config.dap_args) end + ---@type neotest.RunSpec return { command = vim.iter({ python_command, script_path, script_args }):flatten():totable(), context = { results_path = results_path, + container_results_path = container_results_path, stop_stream = stop_stream, + docker = config.docker, }, stream = function() return function() - local lines = stream_data() + if config.docker and config.docker.container then + local copy_stream_cmd = { + "docker", "cp", + config.docker.container .. ":" .. container_stream_path, + stream_path + } + pcall(lib.process.run, copy_stream_cmd) + end + + local lines = {} + pcall(function() + lines = stream_data() + end) local results = {} for _, line in ipairs(lines) do - local result = vim.json.decode(line, { luanil = { object = true } }) - results[result.id] = result.result + if line and line ~= "" then + local success, result = pcall(vim.json.decode, line, { luanil = { object = true } }) + if success and result and result.id then + results[result.id] = result.result + end + end end return results end @@ -124,14 +177,58 @@ return function(config) ---@param result neotest.StrategyResult ---@return neotest.Result[] results = function(spec, result) - spec.context.stop_stream() - local success, data = pcall(lib.files.read, spec.context.results_path) - if not success then - data = "{}" + pcall(function() + spec.context.stop_stream() + end) + + local data = "{}" + + if spec.context.docker then + if spec.context.docker.container then + local copy_cmd = { + "docker", "cp", + spec.context.docker.container .. ":" .. spec.context.container_results_path, + spec.context.results_path + } + local copy_success = lib.process.run(copy_cmd) == 0 + if copy_success then + local read_success, file_data = pcall(lib.files.read, spec.context.results_path) + if read_success then + data = file_data + end + end + end + else + local success, file_data = pcall(lib.files.read, spec.context.results_path) + if success then + data = file_data + end + end + + local results = {} + local parse_success, parsed_results = pcall(vim.json.decode, data, { luanil = { object = true } }) + if parse_success and type(parsed_results) == "table" then + results = parsed_results end - local results = vim.json.decode(data, { luanil = { object = true } }) + + if spec.context.docker then + local translated_results = {} + for test_id, test_result in pairs(results) do + local host_test_id = base.translate_path_to_host(test_id, spec.context.docker) + + if test_result.output_path then + test_result.output_path = base.translate_path_to_host(test_result.output_path, spec.context.docker) + end + + translated_results[host_test_id] = test_result + end + results = translated_results + end + for _, pos_result in pairs(results) do - result.output_path = pos_result.output_path + if pos_result.output_path then + result.output_path = pos_result.output_path + end end return results end, diff --git a/lua/neotest-python/base.lua b/lua/neotest-python/base.lua index 6d4a3d3..b4b3603 100644 --- a/lua/neotest-python/base.lua +++ b/lua/neotest-python/base.lua @@ -80,8 +80,12 @@ function M.get_python_command(root) { stdout = true } ) if success and exit_code == 0 then - python_command_mem[root] = { Path:new(data).filename } - return python_command_mem[root] + local python_path = type(data) == "table" and data.stdout or tostring(data) + python_path = python_path:gsub("\r?\n", "") + if python_path and python_path ~= "" then + python_command_mem[root] = { python_path } + return python_command_mem[root] + end end end @@ -101,7 +105,10 @@ function M.get_script_path() end end - error("neotest.py not found") + vim.schedule(function() + vim.notify("neotest.py not found", vim.log.levels.ERROR) + end) + return "" end ---@param python_command string[] @@ -176,6 +183,102 @@ function M.create_dap_config(python_path, script_path, script_args, dap_args) }, dap_args or {}) end +function M.get_docker_python_command(root, docker_config) + root = root or vim.loop.cwd() + + local container = docker_config.container + local image = docker_config.image + local command = docker_config.command or { "docker", "exec" } + local args = docker_config.args or {} + local workdir = docker_config.workdir or "/app" + + if not container and not image then + vim.schedule(function() + vim.notify("Docker config must specify either 'container' or 'image'", vim.log.levels.ERROR) + end) + return M.get_python_command(root) + end + + local docker_cmd = vim.list_extend({}, command) + + if container then + vim.list_extend(docker_cmd, args) + table.insert(docker_cmd, container) + elseif image then + docker_cmd = { "docker", "run", "--rm" } + vim.list_extend(docker_cmd, args) + vim.list_extend(docker_cmd, { "-v", root .. ":" .. workdir, "-w", workdir, image }) + end + + table.insert(docker_cmd, "python") + return docker_cmd +end + +function M.translate_path_to_container(host_path, docker_config) + if not docker_config then + return host_path + end + + local workdir = docker_config.workdir or "/app" + local cwd = vim.loop.cwd() + + if vim.startswith(host_path, cwd) then + local relative_path = host_path:sub(#cwd + 2) + return workdir .. "/" .. relative_path + end + + return host_path +end + +function M.translate_path_to_host(container_path, docker_config) + if not docker_config then + return container_path + end + + local workdir = docker_config.workdir or "/app" + local cwd = vim.loop.cwd() + + if vim.startswith(container_path, workdir) then + local relative_path = container_path:sub(#workdir + 2) + return cwd .. "/" .. relative_path + end + + return container_path +end + +function M.copy_script_to_container(docker_config) + if not docker_config or not docker_config.container then + return M.get_script_path() + end + + local script_path = M.get_script_path() + local script_dir = vim.fn.fnamemodify(script_path, ":h") + local container_script_path = "/tmp/neotest.py" + local container_dir_path = "/tmp/neotest_python" + + local copy_cmd = { + "docker", "cp", script_path, + docker_config.container .. ":" .. container_script_path + } + + local success = lib.process.run(copy_cmd) == 0 + if not success then + vim.schedule(function() + vim.notify("Failed to copy neotest script to container", vim.log.levels.ERROR) + end) + return script_path + end + + local copy_dir_cmd = { + "docker", "cp", script_dir .. "/neotest_python", + docker_config.container .. ":" .. container_dir_path + } + + lib.process.run(copy_dir_cmd) + + return container_script_path +end + local stored_runners = {} function M.get_runner(python_path) diff --git a/lua/neotest-python/init.lua b/lua/neotest-python/init.lua index 73cfc6d..53fb7c5 100644 --- a/lua/neotest-python/init.lua +++ b/lua/neotest-python/init.lua @@ -1,6 +1,13 @@ local base = require("neotest-python.base") local create_adapter = require("neotest-python.adapter") +---@class neotest-python.DockerConfig +---@field container? string Container name or ID +---@field image? string Docker image name (alternative to container) +---@field command? string[] Custom docker command prefix (default: ["docker", "exec"]) +---@field args? string[] Additional docker arguments (e.g., ["-i", "-w", "/app"]) +---@field workdir? string Working directory inside container (default: "/app") + ---@class neotest-python.AdapterConfig ---@field dap? table ---@field pytest_discover_instances? boolean @@ -8,6 +15,7 @@ local create_adapter = require("neotest-python.adapter") ---@field python? string|string[]|fun(root: string):string[] ---@field args? string[]|fun(runner: string, position: neotest.Position, strategy: string): string[] ---@field runner? string|fun(python_command: string[]): string +---@field docker? neotest-python.DockerConfig Docker configuration for containerized execution local is_callable = function(obj) return type(obj) == "function" or (type(obj) == "table" and obj.__call) @@ -31,7 +39,13 @@ local augment_config = function(config) return python end - return base.get_python(root) + return base.get_python_command(root) + end + end + + if config.docker then + get_python_command = function(root) + return base.get_docker_python_command(root, config.docker) end end @@ -64,6 +78,7 @@ local augment_config = function(config) get_args = get_args, is_test_file = config.is_test_file or base.is_test_file, get_python_command = get_python_command, + docker = config.docker, } end