Repository: conda/conda

Issue:
make stdout/stderr capture in python_api customizable
Let the user customize how and if the standard output/error streams should be captured.
make stdout/stderr capture in python_api customizable
Let the user customize how and if the standard output/error streams should be captured.


Discussion:

Resolving patch:
diff --git a/conda/cli/python_api.py b/conda/cli/python_api.py
--- a/conda/cli/python_api.py
+++ b/conda/cli/python_api.py
@@ -8,7 +8,7 @@
 from ..base.constants import APP_NAME, SEARCH_PATH
 from ..base.context import context
 from ..cli.main import generate_parser
-from ..common.io import captured, replace_log_streams, argv
+from ..common.io import captured, replace_log_streams, argv, CaptureTarget
 from ..common.path import win_path_double_escape
 from ..exceptions import conda_exception_handler
 from ..gateways.logging import initialize_std_loggers
@@ -28,6 +28,10 @@ class Commands:
     UPDATE = "update"
 
 
+STRING = CaptureTarget.STRING
+STDOUT = CaptureTarget.STDOUT
+
+
 def get_configure_parser_function(command):
     module = 'conda.cli.main_' + command
     return import_module(module).configure_parser
@@ -49,8 +53,16 @@ def run_command(command, *arguments, **kwargs):
               has occured, and instead give a non-zero return code
           search_path: an optional non-standard search path for configuration information
               that overrides the default SEARCH_PATH
+          stdout: Define capture behavior for stream sys.stdout. Defaults to STRING.
+              STRING captures as a string.  None leaves stream untouched.
+              Otherwise redirect to file-like object stdout.
+          stderr: Define capture behavior for stream sys.stderr. Defaults to STRING.
+              STRING captures as a string.  None leaves stream untouched.
+              STDOUT redirects to stdout target and returns None as stderr value.
+              Otherwise redirect to file-like object stderr.
 
-    Returns: a tuple of stdout, stderr, and return_code
+    Returns: a tuple of stdout, stderr, and return_code.
+        stdout, stderr are either strings, None or the corresponding file-like function argument.
 
     Examples:
         >>  run_command(Commands.CREATE, "-n newenv python=3 flask", use_exception_handler=True)
@@ -62,6 +74,8 @@ def run_command(command, *arguments, **kwargs):
     initialize_std_loggers()
     use_exception_handler = kwargs.get('use_exception_handler', False)
     configuration_search_path = kwargs.get('search_path', SEARCH_PATH)
+    stdout = kwargs.get('stdout', STRING)
+    stderr = kwargs.get('stderr', STRING)
     p, sub_parsers = generate_parser()
     get_configure_parser_function(command)(sub_parsers)
 
@@ -78,7 +92,8 @@ def run_command(command, *arguments, **kwargs):
     )
     log.debug("executing command >>>  conda %s", command_line)
     try:
-        with argv(['python_api'] + split_command_line), captured() as c, replace_log_streams():
+        python_api_command_line = ['python_api'] + split_command_line
+        with argv(python_api_command_line), captured(stdout, stderr) as c, replace_log_streams():
             if use_exception_handler:
                 return_code = conda_exception_handler(args.func, args, p)
             else:
diff --git a/conda/common/io.py b/conda/common/io.py
--- a/conda/common/io.py
+++ b/conda/common/io.py
@@ -2,6 +2,7 @@
 from __future__ import absolute_import, division, print_function, unicode_literals
 
 from contextlib import contextmanager
+from enum import Enum
 import logging
 from logging import CRITICAL, Formatter, NOTSET, StreamHandler, WARN, getLogger
 import os
@@ -17,6 +18,15 @@
 _FORMATTER = Formatter("%(levelname)s %(name)s:%(funcName)s(%(lineno)d): %(message)s")
 
 
+class CaptureTarget(Enum):
+    """Constants used for contextmanager captured.
+
+    Used similarily like the constants PIPE, STDOUT for stdlib's subprocess.Popen.
+    """
+    STRING = -1
+    STDOUT = -2
+
+
 @contextmanager
 def env_var(name, value, callback=None):
     # NOTE: will likely want to call reset_context() when using this function, so pass
@@ -69,20 +79,58 @@ def cwd(directory):
 
 
 @contextmanager
-def captured():
+def captured(stdout=CaptureTarget.STRING, stderr=CaptureTarget.STRING):
+    """Capture outputs of sys.stdout and sys.stderr.
+
+    If stdout is STRING, capture sys.stdout as a string,
+    if stdout is None, do not capture sys.stdout, leaving it untouched,
+    otherwise redirect sys.stdout to the file-like object given by stdout.
+
+    Behave correspondingly for stderr with the exception that if stderr is STDOUT,
+    redirect sys.stderr to stdout target and set stderr attribute of yielded object to None.
+
+    Args:
+        stdout: capture target for sys.stdout, one of STRING, None, or file-like object
+        stderr: capture target for sys.stderr, one of STRING, STDOUT, None, or file-like object
+
+    Yields:
+        CapturedText: has attributes stdout, stderr which are either strings, None or the
+            corresponding file-like function argument.
+    """
     # NOTE: This function is not thread-safe.  Using within multi-threading may cause spurious
     # behavior of not returning sys.stdout and sys.stderr back to their 'proper' state
     class CapturedText(object):
         pass
     saved_stdout, saved_stderr = sys.stdout, sys.stderr
-    sys.stdout = outfile = StringIO()
-    sys.stderr = errfile = StringIO()
+    if stdout == CaptureTarget.STRING:
+        sys.stdout = outfile = StringIO()
+    else:
+        outfile = stdout
+        if outfile is not None:
+            sys.stdout = outfile
+    if stderr == CaptureTarget.STRING:
+        sys.stderr = errfile = StringIO()
+    elif stderr == CaptureTarget.STDOUT:
+        sys.stderr = errfile = outfile
+    else:
+        errfile = stderr
+        if errfile is not None:
+            sys.stderr = errfile
     c = CapturedText()
     log.info("overtaking stderr and stdout")
     try:
         yield c
     finally:
-        c.stdout, c.stderr = outfile.getvalue(), errfile.getvalue()
+        if stdout == CaptureTarget.STRING:
+            c.stdout = outfile.getvalue()
+        else:
+            c.stdout = outfile
+        if stderr == CaptureTarget.STRING:
+            c.stderr = errfile.getvalue()
+        elif stderr == CaptureTarget.STDOUT:
+            c.stderr = None
+        else:
+            c.stderr = errfile
         sys.stdout, sys.stderr = saved_stdout, saved_stderr
         log.info("stderr and stdout yielding back")
 


Test patch:
diff --git a/tests/common/test_io.py b/tests/common/test_io.py
--- a/tests/common/test_io.py
+++ b/tests/common/test_io.py
@@ -1,8 +1,48 @@
 # -*- coding: utf-8 -*-
 from __future__ import absolute_import, division, print_function, unicode_literals
 
-from conda.common.io import attach_stderr_handler, captured
+from conda.common.io import attach_stderr_handler, captured, CaptureTarget
+from io import StringIO
 from logging import DEBUG, NOTSET, WARN, getLogger
+import sys
+
+
+def test_captured():
+    stdout_text = "stdout text"
+    stderr_text = "stderr text"
+
+    def print_captured(*args, **kwargs):
+        with captured(*args, **kwargs) as c:
+            print(stdout_text, end="")
+            print(stderr_text, end="", file=sys.stderr)
+        return c
+
+    c = print_captured()
+    assert c.stdout == stdout_text
+    assert c.stderr == stderr_text
+
+    c = print_captured(CaptureTarget.STRING, CaptureTarget.STRING)
+    assert c.stdout == stdout_text
+    assert c.stderr == stderr_text
+
+    c = print_captured(stderr=CaptureTarget.STDOUT)
+    assert c.stdout == stdout_text + stderr_text
+    assert c.stderr is None
+
+    caller_stdout = StringIO()
+    caller_stderr = StringIO()
+    c = print_captured(caller_stdout, caller_stderr)
+    assert c.stdout is caller_stdout
+    assert c.stderr is caller_stderr
+    assert caller_stdout.getvalue() == stdout_text
+    assert caller_stderr.getvalue() == stderr_text
+
+    with captured() as outer_c:
+        inner_c = print_captured(None, None)
+    assert inner_c.stdout is None
+    assert inner_c.stderr is None
+    assert outer_c.stdout == stdout_text
+    assert outer_c.stderr == stderr_text
 
 
 def test_attach_stderr_handler():