Repository: gabrieldemarmiesse/python-on-whales (language: python)

Issue:
Docker compose exec
From the documentation is stated that is not implemented.  Any plan to implement it soon? Thanks


Discussion / hints:
Sure, thanks for reporting it. I was wondering if users actually used this functionality. Now I know 
Giving a +1 for this command, I use it frequently in the CLI

Fix patch:
diff --git a/python_on_whales/components/compose/cli_wrapper.py b/python_on_whales/components/compose/cli_wrapper.py
index 01e1ad09..58fd86b1 100644
--- a/python_on_whales/components/compose/cli_wrapper.py
+++ b/python_on_whales/components/compose/cli_wrapper.py
@@ -110,9 +110,50 @@ def events(self):
         """Not yet implemented"""
         raise NotImplementedError
 
-    def exec(self):
-        """Not yet implemented"""
-        raise NotImplementedError
+    def execute(
+        self,
+        service: str,
+        command: List[str],
+        detach: bool = False,
+        envs: Dict[str, str] = {},
+        index: int = 1,
+        tty: bool = True,
+        privileged: bool = False,
+        user: Optional[str] = None,
+        workdir: Union[str, Path, None] = None,
+    ) -> Optional[str]:
+        """Execute a command in a running container.
+
+        # Arguments
+            service: The name of the service.
+            command: The command to execute.
+            detach: If `True`, detach from the container after the command exits. In this case,
+                 nothing is returned by the function. By default, the execute command returns only when the
+                 command has finished running, and the function will raise an exception `DockerException` if the command
+                 exits with a non-zero exit code. If `False`, the command is executed and the stdout is returned.
+            envs: A dictionary of environment variables to set in the container.
+            index: The index of the container to execute the command in (default 1) if there are multiple containers for this service.
+            tty: If `True`, allocate a pseudo-TTY. Use `False` to get the output of the command.
+            privileged: If `True`, run the command in privileged mode.
+            user: The user name to use inside the container.
+            workdir: The working directory inside the container.
+        """
+        full_cmd = self.docker_compose_cmd + ["exec"]
+        full_cmd.add_flag("--detach", detach)
+        for key, value in envs.items():
+            full_cmd.add_simple_arg("--env", f"{key}={value}")
+        full_cmd.add_simple_arg("--index", index)
+        full_cmd.add_flag("--no-TTY", not tty)
+        full_cmd.add_flag("--privileged", privileged)
+        full_cmd.add_simple_arg("--user", user)
+        full_cmd.add_simple_arg("--workdir", workdir)
+        full_cmd += [service] + command
+        if detach:
+            run(full_cmd)
+        elif tty:
+            run(full_cmd, capture_stdout=False, capture_stderr=False)
+        else:
+            return run(full_cmd)
 
     def kill(self, services: Union[str, List[str]] = [], signal: Optional[str] = None):
         """Kills the container(s) of a service


Test patch:
diff --git a/tests/python_on_whales/components/test_compose.py b/tests/python_on_whales/components/test_compose.py
index 9ec58df0..2aeb51de 100644
--- a/tests/python_on_whales/components/test_compose.py
+++ b/tests/python_on_whales/components/test_compose.py
@@ -409,6 +409,96 @@ def test_compose_logs_follow():
     docker.compose.down(timeout=1)
 
 
+@pytest.mark.parametrize("privileged", [True, False])
+def test_compose_execute_no_tty(privileged):
+    docker = DockerClient(
+        compose_files=[
+            PROJECT_ROOT / "tests/python_on_whales/components/dummy_compose.yml"
+        ]
+    )
+    docker.compose.up(["busybox"], detach=True)
+    time.sleep(2)
+    full_output = docker.compose.execute(
+        "busybox", ["echo", "dodo"], tty=False, privileged=privileged
+    )
+    assert full_output == "dodo"
+    docker.compose.down(timeout=1)
+
+
+def test_compose_execute_with_tty():
+    docker = DockerClient(
+        compose_files=[
+            PROJECT_ROOT / "tests/python_on_whales/components/dummy_compose.yml"
+        ]
+    )
+    docker.compose.up(["busybox"], detach=True)
+    time.sleep(2)
+    output = docker.compose.execute("busybox", ["echo", "dodo"], tty=True)
+    assert output is None
+    docker.compose.down(timeout=1)
+
+
+def test_compose_execute_detach():
+    docker = DockerClient(
+        compose_files=[
+            PROJECT_ROOT / "tests/python_on_whales/components/dummy_compose.yml"
+        ]
+    )
+    docker.compose.up(["busybox"], detach=True)
+    t1 = datetime.now()
+    output = docker.compose.execute("busybox", ["sleep", "20"], detach=True)
+    execution_time = datetime.now() - t1
+    assert execution_time.seconds < 20
+    assert output is None
+    docker.compose.down(timeout=1)
+
+
+def test_compose_execute_envs():
+    docker = DockerClient(
+        compose_files=[
+            PROJECT_ROOT / "tests/python_on_whales/components/dummy_compose.yml"
+        ]
+    )
+    docker.compose.up(["busybox"], detach=True)
+    output = docker.compose.execute(
+        "busybox",
+        ["sh", "-c", "echo $VAR1,$VAR2"],
+        envs={"VAR1": "hello", "VAR2": "world"},
+        tty=False,
+    )
+    assert output == "hello,world"
+    docker.compose.down(timeout=1)
+
+
+def test_compose_execute_user():
+    docker = DockerClient(
+        compose_files=[
+            PROJECT_ROOT / "tests/python_on_whales/components/dummy_compose.yml"
+        ]
+    )
+    docker.compose.up(["busybox"], detach=True)
+    output = docker.compose.execute("busybox", ["whoami"], tty=False, user="sync")
+    assert output == "sync"
+    docker.compose.down(timeout=1)
+
+
+def test_compose_execute_workdir():
+    docker = DockerClient(
+        compose_files=[
+            PROJECT_ROOT / "tests/python_on_whales/components/dummy_compose.yml"
+        ]
+    )
+    docker.compose.up(["busybox"], detach=True)
+    assert (
+        docker.compose.execute("busybox", ["pwd"], tty=False, workdir="/tmp") == "/tmp"
+    )
+    assert (
+        docker.compose.execute("busybox", ["pwd"], tty=False, workdir="/proc")
+        == "/proc"
+    )
+    docker.compose.down(timeout=1)
+
+
 def test_compose_single_profile():
     docker = DockerClient(
         compose_files=[