Repository: conan-io/conan

Issue:
self.run() regression: mishandles output in 1.30.1 vs. 1.29.1
I have a recipe which in the deploy() function has this code:

           class Buf:
                def __init__(self):
                    self.buf = []

                def write(self, data):
                    self.buf.append(data)

                def getData(self):
                    return "".join((str(x) for x in self.buf)).strip()

            myBuf = Buf()
            self.run('"python" -c "import sys;sys.stdout.write(\'\\n\'.join((sys.exec_prefix,'
                     'str(sys.version_info[0]), str(sys.version_info[1]))))"',
                     output = myBuf)
            data = myBuf.getData().replace("\r", "").split('\n')
            assert len(data) == 3
This code worked fine, in agreement with the published documentation in version 1.29.1 on Ubuntu 18.04.
In version 1.30.1 the run() command fails because with the error message: **"Buf has no attribute _stream"**.
The documentation: https://docs.conan.io/en/latest/reference/conanfile/other.html#output-and-running
Specifies that the only requirement for the value of the output argument is that it has a write method.
"You can pass **any stream that accepts a write method** like a six.StringIO()" 
As far as I know a stream is not required to have a private member named stream.


Discussion:

Resolving patch:
diff --git a/conans/client/runner.py b/conans/client/runner.py
--- a/conans/client/runner.py
+++ b/conans/client/runner.py
@@ -1,5 +1,4 @@
 import io
-import os
 import subprocess
 import sys
 from subprocess import PIPE, Popen, STDOUT
@@ -7,7 +6,6 @@
 import six
 
 from conans.errors import ConanException
-from conans.unicode import get_cwd
 from conans.util.files import decode_text
 from conans.util.runners import pyinstaller_bundle_env_cleaned
 
@@ -42,7 +40,8 @@ def __call__(self, command, output=True, log_filepath=None, cwd=None, subprocess
             print("*** WARN: Invalid output parameter of type io.StringIO(), "
                   "use six.StringIO() instead ***")
 
-        stream_output = output if output and hasattr(output, "write") else self._output or sys.stdout
+        user_output = output if output and hasattr(output, "write") else None
+        stream_output = user_output or self._output or sys.stdout
         if hasattr(stream_output, "flush"):
             # We do not want output from different streams to get mixed (sys.stdout, os.system)
             stream_output = _UnbufferedWrite(stream_output)
@@ -66,18 +65,18 @@ def __call__(self, command, output=True, log_filepath=None, cwd=None, subprocess
                 with open(log_filepath, "a+") as log_handler:
                     if self._print_commands_to_output:
                         log_handler.write(call_message)
-                    return self._pipe_os_call(command, stream_output, log_handler, cwd)
+                    return self._pipe_os_call(command, stream_output, log_handler, cwd, user_output)
             else:
-                return self._pipe_os_call(command, stream_output, None, cwd)
+                return self._pipe_os_call(command, stream_output, None, cwd, user_output)
 
-    def _pipe_os_call(self, command, stream_output, log_handler, cwd):
+    def _pipe_os_call(self, command, stream_output, log_handler, cwd, user_output):
 
         try:
             # piping both stdout, stderr and then later only reading one will hang the process
             # if the other fills the pip. So piping stdout, and redirecting stderr to stdout,
             # so both are merged and use just a single get_stream_lines() call
-            capture_output = log_handler or not self._log_run_to_output or (
-                    stream_output and isinstance(stream_output._stream, six.StringIO))
+            capture_output = log_handler or not self._log_run_to_output or user_output \
+                             or (stream_output and isinstance(stream_output._stream, six.StringIO))
             if capture_output:
                 proc = Popen(command, shell=isinstance(command, six.string_types), stdout=PIPE,
                              stderr=STDOUT, cwd=cwd)
@@ -113,17 +112,9 @@ def get_stream_lines(the_stream):
         ret = proc.returncode
         return ret
 
-    def _simple_os_call(self, command, cwd):
-        if not cwd:
-            return subprocess.call(command, shell=isinstance(command, six.string_types))
-        else:
-            try:
-                old_dir = get_cwd()
-                os.chdir(cwd)
-                result = subprocess.call(command, shell=isinstance(command, six.string_types))
-            except Exception as e:
-                raise ConanException("Error while executing"
-                                     " '%s'\n\t%s" % (command, str(e)))
-            finally:
-                os.chdir(old_dir)
-            return result
+    @staticmethod
+    def _simple_os_call(command, cwd):
+        try:
+            return subprocess.call(command, cwd=cwd, shell=isinstance(command, six.string_types))
+        except Exception as e:
+            raise ConanException("Error while executing '%s'\n\t%s" % (command, str(e)))


Test patch:
diff --git a/conans/test/functional/conanfile/runner_test.py b/conans/test/functional/conanfile/runner_test.py
--- a/conans/test/functional/conanfile/runner_test.py
+++ b/conans/test/functional/conanfile/runner_test.py
@@ -193,3 +193,25 @@ def source(self):
         client.save({"conanfile.py": conanfile})
         client.run("source .")
         self.assertIn("hello Conan!", client.out)
+
+    def test_custom_stream_error(self):
+        # https://github.com/conan-io/conan/issues/7888
+        conanfile = textwrap.dedent("""
+            from conans import ConanFile
+            class Pkg(ConanFile):
+                def source(self):
+                    class Buf:
+                        def __init__(self):
+                            self.buf = []
+
+                        def write(self, data):
+                            self.buf.append(data)
+
+                    my_buf = Buf()
+                    self.run('echo "Hello"', output=my_buf)
+                    self.output.info("Buffer got msgs {}".format(len(my_buf.buf)))
+            """)
+        client = TestClient()
+        client.save({"conanfile.py": conanfile})
+        client.run("source .")
+        self.assertIn("Buffer got msgs 1", client.out)