Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
```
133 changes: 115 additions & 18 deletions lua/neotest-python/adapter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading