Repository: olofk/edalize

Issue:
Getting stdout from tool
It'd be useful to be able to call a tool and get easy access to stdout and stderr from that tool.

Often I'll have a script that processes these to check that everything went OK.

I'd like to modify `Edatool._run_tool` so that it returns a string containing the output from the tool (combined stdout and stderr).  `_run_tool` would also contain optional argument to save stdout and stderr to files, and/or to output them to the same stdout and stderr as the calling process.

Discussion:

Patch:
diff --git a/README.rst b/README.rst
index 2fe442a..a461276 100644
--- a/README.rst
+++ b/README.rst
@@ -14,7 +14,7 @@ What's this?
 
 Edalize is a Python Library for interacting with EDA tools. It can create project files for supported tools and run them in batch or GUI mode (where supported).
 
-`Edalize introduction video`_
+Award-winning `Edalize introduction video`_
 
 All EDA tools such as Icarus, Yosys, ModelSim, Vivado, Verilator, GHDL, Quartus etc get input HDL files (Verilog and VHDL) and some tool-specific files (constraint files, memory initialization files, IP description files etc). Together with the files, perhaps a couple of Verilog \`defines, some top-level parameters/generics or some tool-specific options are set. Once the configuration is done, a simulation model, netlist or FPGA image is built, and in the case of simulations, the model is also executed, maybe with some extra run-time parameters.
 
diff --git a/edalize/edatool.py b/edalize/edatool.py
index 49ef777..2636312 100644
--- a/edalize/edatool.py
+++ b/edalize/edatool.py
@@ -10,6 +10,59 @@ logger = logging.getLogger(__name__)
 
 if sys.version[0] == '2':
     FileNotFoundError = OSError
+try:
+    import msvcrt
+    _mswindows = True
+except ImportError:
+    _mswindows = False
+
+def subprocess_run_3_9(*popenargs,
+                        input=None, capture_output=False, timeout=None,
+                        check=False, **kwargs):
+    if input is not None:
+        if kwargs.get('stdin') is not None:
+            raise ValueError('stdin and input arguments may not both be used.')
+        kwargs['stdin'] = subprocess.PIPE
+
+    if capture_output:
+        if kwargs.get('stdout') is not None or kwargs.get('stderr') is not None:
+            raise ValueError('stdout and stderr arguments may not be used '
+                             'with capture_output.')
+        kwargs['stdout'] = subprocess.PIPE
+        kwargs['stderr'] = subprocess.PIPE
+
+    with subprocess.Popen(*popenargs, **kwargs) as process:
+        try:
+            stdout, stderr = process.communicate(input, timeout=timeout)
+        except TimeoutExpired as exc:
+            process.kill()
+            if _mswindows:
+                # Windows accumulates the output in a single blocking
+                # read() call run on child threads, with the timeout
+                # being done in a join() on those threads.  communicate()
+                # _after_ kill() is required to collect that and add it
+                # to the exception.
+                exc.stdout, exc.stderr = process.communicate()
+            else:
+                # POSIX _communicate already populated the output so
+                # far into the TimeoutExpired exception.
+                process.wait()
+            raise
+        except:  # Including KeyboardInterrupt, communicate handled that.
+            process.kill()
+            # We don't call process.wait() as .__exit__ does that for us.
+            raise
+        retcode = process.poll()
+        if check and retcode:
+            raise subprocess.CalledProcessError(retcode, process.args,
+                                     output=stdout, stderr=stderr)
+    return subprocess.CompletedProcess(process.args, retcode, stdout, stderr)
+
+
+if sys.version_info < (3, 7):
+    run = subprocess_run_3_9
+else:
+    run = subprocess.run
 
 # Jinja2 tests and filters, available in all templates
 def jinja_filter_param_value_str(value, str_quote_style="", bool_is_str=False):
@@ -295,30 +348,47 @@ class Edatool(object):
             logger.debug("Environment: " + str(_env))
             logger.debug("Working directory: " + self.work_root)
             try:
-                subprocess.check_call(script['cmd'],
-                                      cwd = self.work_root,
-                                      env = _env)
+                cp = run(script['cmd'],
+                                    cwd = self.work_root,
+                                    env = _env,
+				    capture_output=True,
+                                    check = True)
             except FileNotFoundError as e:
                 msg = "Unable to run {} script '{}': {}"
                 raise RuntimeError(msg.format(hook_name, script['name'], str(e)))
             except subprocess.CalledProcessError as e:
-                msg = "{} script '{}' exited with error code {}"
-                raise RuntimeError(msg.format(hook_name, script['name'], e.returncode))
+                msg = "{} script '{}': {} exited with error code {}".format(hook_name, script['name'], e.cmd, e.returncode)
+                logger.debug(msg)
+                if e.stderr:
+                    logger.debug("=== STDERR ===")
+                    logger.debug(e.stderr)
+                raise RuntimeError(msg)
+
+            return (cp.returncode, cp.stdout, cp.stderr)
 
     def _run_tool(self, cmd, args=[]):
         logger.debug("Running " + cmd)
         logger.debug("args  : " + ' '.join(args))
 
         try:
-            subprocess.check_call([cmd] + args,
-                                  cwd = self.work_root,
-                                  stdin=subprocess.PIPE),
+            cp = run([cmd] + args,
+				cwd = self.work_root,
+				stdin=subprocess.PIPE,
+				capture_output=True,
+				check=True)
         except FileNotFoundError:
-            _s = "Command '{}' not found. Make sure it is in $PATH"
-            raise RuntimeError(_s.format(cmd))
-        except subprocess.CalledProcessError:
-            _s = "'{}' exited with an error code"
-            raise RuntimeError(_s.format(cmd))
+            _s = "Command '{}' not found. Make sure it is in $PATH".format(cmd)
+            raise RuntimeError(_s)
+        except subprocess.CalledProcessError as e:
+            _s = "'{}' exited with an error: {}".format(e.cmd, e.returncode)
+            logger.debug(_s)
+
+            if e.stderr:
+                logger.debug("=== STDERR ===")
+                logger.debug(e.stderr)
+            
+            raise RuntimeError(_s)
+        return cp.returncode, cp.stdout, cp.stderr
 
     def _filter_verilog_files(src_file):
         ft = src_file.file_type


Test patch:
diff --git a/tests/test_vunit.py b/tests/test_vunit.py
index 2afde03..7b09eee 100644
--- a/tests/test_vunit.py
+++ b/tests/test_vunit.py
@@ -2,6 +2,7 @@ import pytest
 import os.path
 from unittest.mock import patch, MagicMock
 from edalize_common import make_edalize_test
+import edalize.edatool
 
 
 def test_vunit_codegen(make_edalize_test):
@@ -38,7 +39,7 @@ def test_vunit_hooks(tmpdir):
 
     backend = get_edatool(tool)(edam=edam, work_root=work_root)
 
-    original_impl = subprocess.check_call
+    original_impl = edalize.edatool.run
 
     def subprocess_intercept(args, **kwargs):
         if len(args) > 1 and args[1].endswith('run.py'):
@@ -47,11 +48,12 @@ def test_vunit_hooks(tmpdir):
                 spec = importlib.util.spec_from_file_location("__main__", args[1])
                 runner_script = importlib.util.module_from_spec(spec)
 
-                spec.loader.exec_module(runner_script)
+                returncode = spec.loader.exec_module(runner_script)
+                return subprocess.CompletedProcess(args, returncode)
         else:
-            original_impl(args, **kwargs)
+            return original_impl(args, **kwargs)
 
-    with mock.patch('subprocess.check_call', new=subprocess_intercept):
+    with mock.patch('edalize.edatool.run', new=subprocess_intercept):
         backend.configure()
 
         with mock.patch('edalize.vunit_hooks.VUnitRunner') as hooks_constructor: