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

Issue:
Add stream_logs argument to all compose functions that can stream some output
Every compose fonction that logs something interesting (up, exec, run, push, pull, start, build) should have a new argument (or rename the existing one): stream_logs. When this is enabled, an iterator is returned. This iterator is a tuple[str, bytes]. You can see how this iterator looks like here: https://gabrieldemarmiesse.github.io/python-on-whales/user_guide/docker_run/#stream-the-output

If the user set in any function stream_logs=True and quiet=True at the same time, Python-on-whales should throw an error saying that it's not possible to have both arguments being True at once.

When stream_logs=False, then either None is returned, or the already existing object, like in the function docker.compose.run() which already does that and returns a Container when not streaming.

If a function already has a stream argument, then this argument must be changed to stream_logs.

All of that is possible with the function stream_stdout_and_stderr. An example can be found with docker.compose.run().

To help with the conditional type return, we should use the typing.overload decorator. It will allow mypy, pyright, vscode and pycharm tell the user when the iterator is returned or when something else is returned depending on the input arguments. So the conditional type on return should not be a big deal.

Here are the functions to modify

- [x] `docker.compose.build` #494
- [x] `docker.compose.create` #498
- [x] `docker.compose.down` #496
- [ ] `docker.compose.execute`
- [ ] `docker.compose.kill`
- [ ] `docker.compose.pause`
- [x] `docker.compose.pull` #497
- [ ] `docker.compose.push`
- [ ] `docker.compose.restart`
- [ ] `docker.compose.rm`
- [x] `docker.compose.start` #508
- [x] `docker.compose.stop` #507 
- [ ] `docker.compose.unpause`
- [x] `docker.compose.up` #505 

If you're interested in this ticket, please make one pull request per function. And if you're looking for examples, you can take a look at the pull requests already done on this subject.


Discussion / hints:

Fix patch:
diff --git a/python_on_whales/components/compose/cli_wrapper.py b/python_on_whales/components/compose/cli_wrapper.py
index 84da9545..35d19c0c 100644
--- a/python_on_whales/components/compose/cli_wrapper.py
+++ b/python_on_whales/components/compose/cli_wrapper.py
@@ -803,6 +803,56 @@ def unpause(self, services: Union[str, List[str], None] = None):
             full_cmd += to_list(services)
         run(full_cmd)
 
+    @overload
+    def up(
+        self,
+        services: Union[List[str], str, None] = ...,
+        build: bool = ...,
+        detach: bool = ...,
+        abort_on_container_exit: bool = ...,
+        scales: Dict[str, int] = ...,
+        attach_dependencies: bool = ...,
+        force_recreate: bool = ...,
+        recreate: bool = ...,
+        no_build: bool = ...,
+        remove_orphans: bool = ...,
+        renew_anon_volumes: bool = ...,
+        color: bool = ...,
+        log_prefix: bool = ...,
+        start: bool = ...,
+        quiet: bool = ...,
+        wait: bool = ...,
+        no_attach_services: Union[List[str], str, None] = ...,
+        pull: Literal["always", "missing", "never", None] = ...,
+        stream_logs: Literal[True] = ...,
+    ) -> Iterable[Tuple[str, bytes]]:
+        ...
+
+    @overload
+    def up(
+        self,
+        services: Union[List[str], str, None] = ...,
+        build: bool = ...,
+        detach: bool = ...,
+        abort_on_container_exit: bool = ...,
+        scales: Dict[str, int] = ...,
+        attach_dependencies: bool = ...,
+        force_recreate: bool = ...,
+        recreate: bool = ...,
+        no_build: bool = ...,
+        remove_orphans: bool = ...,
+        renew_anon_volumes: bool = ...,
+        color: bool = ...,
+        log_prefix: bool = ...,
+        start: bool = ...,
+        quiet: bool = ...,
+        wait: bool = ...,
+        no_attach_services: Union[List[str], str, None] = ...,
+        pull: Literal["always", "missing", "never", None] = ...,
+        stream_logs: Literal[False] = ...,
+    ) -> None:
+        ...
+
     def up(
         self,
         services: Union[List[str], str, None] = None,
@@ -823,6 +873,7 @@ def up(
         wait: bool = False,
         no_attach_services: Union[List[str], str, None] = None,
         pull: Literal["always", "missing", "never", None] = None,
+        stream_logs: bool = False,
     ):
         """Start the containers.
 
@@ -861,12 +912,18 @@ def up(
             wait: Wait for services to be running|healthy. Implies detached mode.
             no_attach_services: The services not to attach to.
             pull: Pull image before running (“always”|”missing”|”never”).
-
-        # Returns
-            `None` at the moment. The plan is to be able to capture and stream the logs later.
-            It's not yet implemented.
-
+            stream_logs: If `False` this function returns None. If `True`, this
+                function returns an Iterable of `Tuple[str, bytes]` where the first element
+                is the type of log (`"stdin"` or `"stdout"`). The second element is the log itself,
+                as bytes, you'll need to call `.decode()` if you want the logs as `str`.
+                See [the streaming guide](https://gabrieldemarmiesse.github.io/python-on-whales/user_guide/docker_run/#stream-the-output) if you are
+                not familiar with the streaming of logs in Python-on-whales.
         """
+        if quiet and stream_logs:
+            raise ValueError(
+                "It's not possible to have stream_logs=True and quiet=True at the same time. "
+                "Only one can be activated at a time."
+            )
         full_cmd = self.docker_compose_cmd + ["up"]
         full_cmd.add_flag("--build", build)
         full_cmd.add_flag("--detach", detach)
@@ -895,8 +952,12 @@ def up(
         elif services is not None:
             services = to_list(services)
             full_cmd += services
-        # important information is written to both stdout AND stderr.
-        run(full_cmd, capture_stdout=quiet, capture_stderr=quiet)
+
+        if stream_logs:
+            return stream_stdout_and_stderr(full_cmd)
+        else:
+            # important information is written to both stdout AND stderr.
+            run(full_cmd, capture_stdout=quiet, capture_stderr=quiet)
 
     def version(self) -> str:
         """Returns the version of docker compose as a `str`."""


Test patch:
diff --git a/tests/python_on_whales/components/test_compose.py b/tests/python_on_whales/components/test_compose.py
index e52e0fff..96e56998 100644
--- a/tests/python_on_whales/components/test_compose.py
+++ b/tests/python_on_whales/components/test_compose.py
@@ -274,6 +274,24 @@ def test_docker_compose_up_build():
         docker.compose.down()
 
 
+def test_docker_compose_up_stream():
+    logs = list(
+        docker.compose.up(
+            ["my_service", "busybox", "alpine"],
+            build=True,
+            stream_logs=True,
+            detach=True,
+            recreate=True,
+        )
+    )
+    assert len(logs) >= 2
+    full_logs_as_binary = b"".join(log for _, log in logs)
+    assert b"components_alpine_1" in full_logs_as_binary
+    assert b"components_my_service_1" in full_logs_as_binary
+    assert b"components_busybox_1" in full_logs_as_binary
+    docker.compose.down()
+
+
 def test_docker_compose_up_stop_rm():
     docker.compose.up(["my_service", "busybox", "alpine"], build=True, detach=True)
     docker.compose.stop(timeout=timedelta(seconds=3))