Repository: mesonbuild/meson

Issue:
run_command should be able to fail if it doesn't succeed
The example on how to use external commands:

http://mesonbuild.com/External-commands.html

as well as my personal experience with them is rather error prone. I think that in practice one has to always check for the return code, and >90% percent of the time, you want to fail the build if it is not zero. It is cumbersome having to do this manually every time (and making sure that e.g. the stdout is not used before the test). 

To give a concrete example, a line like this:
```
applgrid_libs = run_command(applgrid_config, '--ldflags').stdout().strip()
```
that I find convenient and clear in reality needs to be separated into the result of  `run_command` captured in an intermediate variable, a test for the return code, and finally the result I am interested in.

Python's [subprocess.run()](https://docs.python.org/3.5/library/subprocess.html#subprocess.run) has a `check` argument that raises an exception on failure. I think something like that would improve ergonomics sensibly. Even better if it was the default, but that's perhaps asking too much.


Discussion:

Resolving patch:
diff --git a/mesonbuild/interpreter.py b/mesonbuild/interpreter.py
--- a/mesonbuild/interpreter.py
+++ b/mesonbuild/interpreter.py
@@ -92,18 +92,18 @@ def stderr_method(self, args, kwargs):
 
 class RunProcess(InterpreterObject):
 
-    def __init__(self, cmd, args, source_dir, build_dir, subdir, mesonintrospect, in_builddir=False):
+    def __init__(self, cmd, args, source_dir, build_dir, subdir, mesonintrospect, in_builddir=False, check=False):
         super().__init__()
         if not isinstance(cmd, ExternalProgram):
             raise AssertionError('BUG: RunProcess must be passed an ExternalProgram')
-        pc, self.stdout, self.stderr = self.run_command(cmd, args, source_dir, build_dir, subdir, mesonintrospect, in_builddir)
+        pc, self.stdout, self.stderr = self.run_command(cmd, args, source_dir, build_dir, subdir, mesonintrospect, in_builddir, check)
         self.returncode = pc.returncode
         self.methods.update({'returncode': self.returncode_method,
                              'stdout': self.stdout_method,
                              'stderr': self.stderr_method,
                              })
 
-    def run_command(self, cmd, args, source_dir, build_dir, subdir, mesonintrospect, in_builddir):
+    def run_command(self, cmd, args, source_dir, build_dir, subdir, mesonintrospect, in_builddir, check=False):
         command_array = cmd.get_command() + args
         env = {'MESON_SOURCE_ROOT': source_dir,
                'MESON_BUILD_ROOT': build_dir,
@@ -124,6 +124,10 @@ def run_command(self, cmd, args, source_dir, build_dir, subdir, mesonintrospect,
             mlog.debug('----stderr----')
             mlog.debug(e)
             mlog.debug('')
+
+            if check and p.returncode != 0:
+                raise InterpreterException('Command "{}" failed with status {}.'.format(' '.join(command_array), p.returncode))
+
             return p, o, e
         except FileNotFoundError:
             raise InterpreterException('Could not execute command "%s".' % ' '.join(command_array))
@@ -1679,6 +1683,7 @@ def get_cross_property_method(self, args, kwargs):
                     'install_subdir': {'exclude_files', 'exclude_directories', 'install_dir', 'install_mode', 'strip_directory'},
                     'jar': build.known_jar_kwargs,
                     'project': {'version', 'meson_version', 'default_options', 'license', 'subproject_dir'},
+                    'run_command': {'check'},
                     'run_target': {'command', 'depends'},
                     'shared_library': build.known_shlib_kwargs,
                     'shared_module': build.known_shmod_kwargs,
@@ -1957,7 +1962,7 @@ def validate_arguments(self, args, argcount, arg_types):
                 if not isinstance(actual, wanted):
                     raise InvalidArguments('Incorrect argument type.')
 
-    @noKwargs
+    @permittedKwargs(permitted_kwargs['run_command'])
     def func_run_command(self, node, args, kwargs):
         return self.run_command_impl(node, args, kwargs)
 
@@ -1968,6 +1973,11 @@ def run_command_impl(self, node, args, kwargs, in_builddir=False):
         cargs = args[1:]
         srcdir = self.environment.get_source_dir()
         builddir = self.environment.get_build_dir()
+
+        check = kwargs.get('check', False)
+        if not isinstance(check, bool):
+            raise InterpreterException('Check must be boolean.')
+
         m = 'must be a string, or the output of find_program(), files() '\
             'or configure_file(), or a compiler object; not {!r}'
         if isinstance(cmd, ExternalProgramHolder):
@@ -2020,7 +2030,7 @@ def run_command_impl(self, node, args, kwargs, in_builddir=False):
                     if a not in self.build_def_files:
                         self.build_def_files.append(a)
         return RunProcess(cmd, expanded_args, srcdir, builddir, self.subdir,
-                          self.environment.get_build_command() + ['introspect'], in_builddir)
+                          self.environment.get_build_command() + ['introspect'], in_builddir, check)
 
     @stringArgs
     def func_gettext(self, nodes, args, kwargs):


Test patch:
diff --git a/test cases/failing/75 run_command unclean exit/meson.build b/test cases/failing/75 run_command unclean exit/meson.build
new file mode 100644
--- /dev/null
+++ b/test cases/failing/75 run_command unclean exit/meson.build	
@@ -0,0 +1,4 @@
+project('run_command unclean exit', 'c')
+
+rcprog = find_program('./returncode.py')
+run_command(rcprog, '1', check : true)
diff --git a/test cases/failing/75 run_command unclean exit/returncode.py b/test cases/failing/75 run_command unclean exit/returncode.py
new file mode 100755
--- /dev/null
+++ b/test cases/failing/75 run_command unclean exit/returncode.py	
@@ -0,0 +1,4 @@
+#!/usr/bin/env python3
+
+import sys
+exit(int(sys.argv[1]))