Skip to content

Commit 7dee471

Browse files
authored
feat: extended configuration options for container.exec method (#1050)
The branch adds the `ExecConfig` struct (patterned on the [Java implementation of the same feature](https://github.com/testcontainers/testcontainers-java/blob/main/core/src/main/java/org/testcontainers/containers/ExecConfig.java)) and includes it as a valid input for `DockerContainer.exec()`. This allows setting the running user, working directory, environment variables, and privileged mode. Other features become easy extensions of the interface. Also included in the branch: - Extensions to the core test suite, pinning existing behavior of `.exec()` that wasn't previously tested, notably the fact that `str`-typed commands _are not_ executed in shell mode - Rewrite of `docs/features/executing_commands.md`, which incorrect prior to this branch. It now aligns with the actual code
1 parent a347648 commit 7dee471

3 files changed

Lines changed: 305 additions & 80 deletions

File tree

docs/features/executing_commands.md

Lines changed: 57 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -4,104 +4,80 @@ Testcontainers-Python provides several ways to execute commands inside container
44

55
## Basic Command Execution
66

7-
The simplest way to execute a command is using the `exec` method:
7+
The simplest way to execute a command is using the `exec` method, passing either an argv list or a string:
88

99
```python
10-
from testcontainers.community.generic import GenericContainer
10+
from testcontainers.core.container import DockerContainer
1111

12-
with GenericContainer("alpine:latest") as container:
13-
# Execute a simple command
14-
exit_code, output = container.exec(["ls", "-la"])
15-
print(output) # Command output as string
16-
```
12+
with DockerContainer("alpine:latest") as container:
13+
# Execute a simple command (argv form)
14+
result = container.exec(["ls", "-la"])
15+
print(result.exit_code) # 0
16+
print(result.output) # command output as bytes
17+
print(result.output.decode()) # ...decoded to str
1718

18-
## Command Execution with Options
19+
# A string is also accepted
20+
result = container.exec("ls -la")
21+
```
1922

20-
You can customize command execution with various options:
23+
`exec` returns a named `(exit_code, output)` tuple, so you can also unpack it directly:
2124

2225
```python
23-
with GenericContainer("alpine:latest") as container:
24-
# Execute command with user
25-
exit_code, output = container.exec(
26-
["whoami"],
27-
user="nobody"
28-
)
29-
30-
# Execute command with environment variables
31-
exit_code, output = container.exec(
32-
["echo", "$TEST_VAR"],
33-
environment={"TEST_VAR": "test_value"}
34-
)
35-
36-
# Execute command with working directory
37-
exit_code, output = container.exec(
38-
["pwd"],
39-
workdir="/tmp"
40-
)
26+
exit_code, output = container.exec(["ls", "-la"])
4127
```
4228

43-
## Interactive Commands
29+
> **A string command is *not* run through a shell.** `docker-py` tokenizes it with `shlex.split`, so shell features such as pipes, redirections, and variable expansion are passed through literally — `container.exec("echo $HOME")` prints the text `$HOME`, not your home directory. When you need shell behavior, invoke a shell explicitly: `container.exec(["sh", "-c", "echo $HOME"])`.
4430
45-
For interactive commands, you can use the `exec_interactive` method:
31+
## Command Execution with Options
32+
33+
To customize how a command runs — the user, environment, or working directory — pass an `ExecConfig`:
4634

4735
```python
48-
with GenericContainer("alpine:latest") as container:
49-
# Start an interactive shell
50-
container.exec_interactive(["sh"])
51-
```
36+
from testcontainers.core.container import DockerContainer, ExecConfig
5237

53-
## Command Execution with Timeout
38+
with DockerContainer("alpine:latest") as container:
39+
# Execute command as a specific user
40+
result = container.exec(ExecConfig(command=["whoami"], user="nobody"))
5441

55-
You can set a timeout for command execution:
42+
# Execute command with environment variables
43+
# (use a command that reads the environment, e.g. printenv -- a bare
44+
# argv command is not shell-expanded, see the note above)
45+
result = container.exec(ExecConfig(command=["printenv", "TEST_VAR"], environment={"TEST_VAR": "test_value"}))
5646

57-
```python
58-
with GenericContainer("alpine:latest") as container:
59-
# Execute command with timeout
60-
try:
61-
exit_code, output = container.exec(
62-
["sleep", "10"],
63-
timeout=5 # Timeout in seconds
64-
)
65-
except TimeoutError:
66-
print("Command timed out")
47+
# Execute command in a working directory (str or pathlib.Path)
48+
result = container.exec(ExecConfig(command=["pwd"], workdir="/tmp"))
6749
```
6850

69-
## Command Execution with Privileges
70-
71-
For commands that require elevated privileges:
51+
`ExecConfig` is a frozen dataclass: only `command` is required, and `user`, `environment`, `workdir`, and `privileged` are optional. Because it is immutable, the idiomatic way to derive a variant is `dataclasses.replace`:
7252

7353
```python
74-
with GenericContainer("alpine:latest") as container:
75-
# Execute command with privileges
76-
exit_code, output = container.exec(
77-
["mount"],
78-
privileged=True
79-
)
54+
from dataclasses import replace
55+
56+
base = ExecConfig(command=["pwd"], workdir="/tmp")
57+
in_var = replace(base, workdir="/var")
8058
```
8159

82-
## Command Execution with TTY
60+
## Command Execution with Privileges
8361

84-
For commands that require a TTY:
62+
For commands that require elevated privileges, set `privileged=True`:
8563

8664
```python
87-
with GenericContainer("alpine:latest") as container:
88-
# Execute command with TTY
89-
exit_code, output = container.exec(
90-
["top"],
91-
tty=True
92-
)
65+
from testcontainers.core.container import DockerContainer, ExecConfig
66+
67+
with DockerContainer("alpine:latest") as container:
68+
# Execute command with privileges
69+
result = container.exec(ExecConfig(command=["mount"], privileged=True))
9370
```
9471

9572
## Best Practices
9673

97-
1. Use appropriate timeouts for long-running commands
98-
2. Handle command failures gracefully
99-
3. Use environment variables for configuration
100-
4. Consider security implications of privileged commands
101-
5. Clean up after command execution
102-
6. Use appropriate user permissions
103-
7. Handle command output appropriately
104-
8. Consider using shell scripts for complex commands
74+
1. Handle command failures gracefully — check `exit_code` rather than assuming success
75+
2. Use environment variables for configuration
76+
3. Consider security implications of privileged commands
77+
4. Clean up after command execution
78+
5. Use appropriate user permissions
79+
6. Decode `output` from bytes when you need text
80+
7. Use an explicit `["sh", "-c", ...]` invocation for commands that rely on shell features
10581

10682
## Common Use Cases
10783

@@ -121,23 +97,27 @@ with PostgresContainer() as postgres:
12197
### File Operations
12298

12399
```python
124-
with GenericContainer("alpine:latest") as container:
100+
from testcontainers.core.container import DockerContainer
101+
102+
with DockerContainer("alpine:latest") as container:
125103
# Create a directory
126104
container.exec(["mkdir", "-p", "/data"])
127105

128106
# Set permissions
129107
container.exec(["chmod", "755", "/data"])
130108

131109
# List files
132-
exit_code, output = container.exec(["ls", "-la", "/data"])
110+
result = container.exec(["ls", "-la", "/data"])
133111
```
134112

135113
### Service Management
136114

137115
```python
138-
with GenericContainer("nginx:alpine") as container:
116+
from testcontainers.core.container import DockerContainer
117+
118+
with DockerContainer("nginx:alpine") as container:
139119
# Check service status
140-
exit_code, output = container.exec(["nginx", "-t"])
120+
result = container.exec(["nginx", "-t"])
141121

142122
# Reload configuration
143123
container.exec(["nginx", "-s", "reload"])
@@ -151,7 +131,6 @@ If you encounter issues with command execution:
151131
2. Verify user permissions
152132
3. Check container state
153133
4. Verify command availability
154-
5. Check for timeout issues
155-
6. Verify environment variables
156-
7. Check working directory
157-
8. Verify TTY requirements
134+
5. Verify environment variables
135+
6. Check the working directory
136+
7. Remember that string commands are tokenized, not shell-interpreted

src/testcontainers/core/container.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pathlib
55
import sys
66
import tarfile
7+
from dataclasses import dataclass
78
from os import PathLike
89
from socket import socket
910
from types import TracebackType
@@ -45,6 +46,34 @@ class BytesExecResult(ExecResult):
4546
output: bytes
4647

4748

49+
@dataclass(frozen=True)
50+
class ExecConfig:
51+
"""Configuration for a command executed inside a running container.
52+
53+
Backend-agnostic data carrier. Only `command` is required, and every other field
54+
defaults to `None`/`False`.
55+
56+
`command` accepts either an argv `list[str]` or a `str`. Both are forwarded untouched.
57+
"""
58+
59+
command: Union[str, list[str]]
60+
user: Optional[str] = None
61+
environment: Optional[dict[str, str]] = None
62+
workdir: Optional[Union[str, PathLike[str]]] = None
63+
privileged: bool = False
64+
65+
def to_exec_run_kwargs(self) -> dict[str, Any]:
66+
"""Yield the kwargs-dict equivalent for a `docker-py` `exec_run()` call."""
67+
68+
return {
69+
"cmd": self.command,
70+
"user": self.user or "",
71+
"environment": self.environment,
72+
"workdir": str(self.workdir) if self.workdir is not None else None,
73+
"privileged": self.privileged,
74+
}
75+
76+
4877
class DockerContainer:
4978
"""
5079
@@ -358,10 +387,11 @@ def status(self) -> str:
358387
return "not_started"
359388
return self._container.status
360389

361-
def exec(self, command: Union[str, list[str]]) -> BytesExecResult:
390+
def exec(self, command: Union[str, list[str], ExecConfig]) -> BytesExecResult:
362391
if not self._container:
363392
raise ContainerStartException("Container should be started before executing a command")
364-
result = self._container.exec_run(command)
393+
config = command if isinstance(command, ExecConfig) else ExecConfig(command=command)
394+
result = self._container.exec_run(**config.to_exec_run_kwargs())
365395
assert isinstance(result.output, bytes)
366396
return BytesExecResult(result[0], result[1])
367397

0 commit comments

Comments
 (0)