Repository: docker/docker-py

Issue:
execute does not return error code
Hi I was using python API and I'm wondering how to get status code from a command?

Some exception with status code could be raised or maybe execute command can return 3 values (2 generators for stdout stderr and status code)?



Discussion:
@spinus just checked the Docker Remote API that docker-py calls under the covers, there seems to be no support for getting the status code there (https://docs.docker.com/reference/api/docker_remote_api_v1.16/) so docker-py won't be able to implement this i think

:-( that's very very sad news. Thanks.

@dims hey, I found that they implemented this feature recently.
https://github.com/docker/docker/issues/9432
https://github.com/docker/docker/pull/9208

```
git tag --contains 90928eb1140fc0394e2a79d5e9a91dbc0f02484c
v1.4.0
v1.4.1
```

is there any hope to see this in docker-py then?

@spinus yep, should be possible to add the support. definitely promising.

Looks like the docker CLI calls /exec/xxxxxx/json to get information about the executed process and there is a "ExitCode" in the response json.

http://paste.openstack.org/show/155353/

I use somme ugly monkey patch, for that :

``` python
class ExecuteFuture(object):

    def __init__(self, client, command_id, flow):
        self.client = client
        self.command_id = command_id
        self.flow = flow

    def __iter__(self):
        return self.flow

    def wait(self):
        if six.PY3:
            return bytes().join(
                [x for x in self.client._multiplexed_buffer_helper(self.flow)]
            )
        else:
            return str().join(
                [x for x in self.client._multiplexed_buffer_helper(self.flow)]
            )

    def inspect(self):
        url = self.client._url('/exec/{0}/json'.format(self.command_id))
        res = self.client._get(url)
        self.client._raise_for_status(res)
        return res.json()

def execute_return(self, container, cmd, detach=False, stdout=True, stderr=True,
            stream=False, tty=False):
    if utils.compare_version('1.15', self._version) < 0:
        raise errors.APIError('Exec is not supported in API < 1.15')
    if isinstance(container, dict):
        container = container.get('Id')
    if isinstance(cmd, six.string_types):
        cmd = shlex.split(str(cmd))

    data = {
        'Container': container,
        'User': '',
        'Privileged': False,
        'Tty': tty,
        'AttachStdin': False,
        'AttachStdout': stdout,
        'AttachStderr': stderr,
        'Detach': detach,
        'Cmd': cmd
    }

    # create the command
    url = self._url('/containers/{0}/exec'.format(container))
    res = self._post_json(url, data=data)
    self._raise_for_status(res)

    # start the command
    cmd_id = res.json().get('Id')
    res = self._post_json(self._url('/exec/{0}/start'.format(cmd_id)),
                        data=data, stream=stream)
    self._raise_for_status(res)

    return ExecuteFuture(self, cmd_id, res)

```

The answer is iterable, and you can inspect the command. Is it nice enough for a pull request?

@athoune I'm not sure using a specific class/object for an alternate version of execute is the best way to go, I think ideally you'd want it as an option in the original execute function or as an alternate execute function.

That being said, I cannot thank you enough for sharing your code with us, it helped me create my own version of execute that returns the exit code instead of the output.

Thank you thank you thank you thank you!

For what it's worth, this is my solution to the problem (and a huge shoutout to @athoune for his answer which helped me). Named `docker_call` after our favorite `subprocess` function, it does virtually the same thing as `Client.execute` except it returns the return code instead if `stream=True` it will print the output of `stdout` and `stderr` to the console:

``` python
def docker_call(cmd, docker_client, container_name, smart_replace=True, verbose=True):
    if isinstance(cmd, six.string_types):
        cmd = shlex.split(str(cmd))
    data = {
        'Container': container_name,
        'User': '',
        'Privileged':False,
        'Tty': False,
        'AttachStdin': False,
        'AttachStdout': verbose,
        'AttachStderr': verbose,
        'Detach': False,  #Might need this option in the future.
        'Cmd': cmd
    }

    url = docker_client._url('/containers/{0}/exec'.format(container_name))
    res = docker_client._post_json(url, data=data)
    docker_client._raise_for_status(res)
    cmd_id = res.json().get('Id')
    res = docker_client._post_json(docker_client._url('/exec/{0}/start'.format(cmd_id)), data=data, stream=verbose)
    docker_client._raise_for_status(res)
    for i in docker_client._multiplexed_buffer_helper(res):
        logging.debug(i)
    url = docker_client._url('/exec/{0}/json'.format(cmd_id))
    res = docker_client._get(url)
    docker_client._raise_for_status(res)
    return res.json().get('ExitCode')
```

@pixelbombs you don't have to break compatibility. You can return Stream object or str like now it is done and add attribute "exit_code". Like http://amoffat.github.io/sh/#exit-codes


Resolving patch:
diff --git a/docker/client.py b/docker/client.py
--- a/docker/client.py
+++ b/docker/client.py
@@ -17,6 +17,7 @@
 import re
 import shlex
 import struct
+import warnings
 from datetime import datetime
 
 import requests
@@ -515,6 +516,17 @@ def events(self, since=None, until=None, filters=None, decode=None):
     @check_resource
     def execute(self, container, cmd, detach=False, stdout=True, stderr=True,
                 stream=False, tty=False):
+        warnings.warn(
+            'Client.execute is being deprecated. Please use exec_create & '
+            'exec_start instead', DeprecationWarning
+        )
+        create_res = self.exec_create(
+            container, cmd, detach, stdout, stderr, tty
+        )
+
+        return self.exec_start(create_res, detach, tty, stream)
+
+    def exec_create(self, container, cmd, stdout=True, stderr=True, tty=False):
         if utils.compare_version('1.15', self._version) < 0:
             raise errors.InvalidVersion('Exec is not supported in API < 1.15')
         if isinstance(container, dict):
@@ -530,18 +542,47 @@ def execute(self, container, cmd, detach=False, stdout=True, stderr=True,
             'AttachStdin': False,
             'AttachStdout': stdout,
             'AttachStderr': stderr,
-            'Detach': detach,
             'Cmd': cmd
         }
 
-        # create the command
         url = self._url('/containers/{0}/exec'.format(container))
         res = self._post_json(url, data=data)
-        self._raise_for_status(res)
+        return self._result(res, True)
+
+    def exec_inspect(self, exec_id):
+        if utils.compare_version('1.15', self._version) < 0:
+            raise errors.InvalidVersion('Exec is not supported in API < 1.15')
+        if isinstance(exec_id, dict):
+            exec_id = exec_id.get('Id')
+        res = self._get(self._url("/exec/{0}/json".format(exec_id)))
+        return self._result(res, True)
+
+    def exec_resize(self, exec_id, height=None, width=None):
+        if utils.compare_version('1.15', self._version) < 0:
+            raise errors.InvalidVersion('Exec is not supported in API < 1.15')
+        if isinstance(exec_id, dict):
+            exec_id = exec_id.get('Id')
+        data = {
+            'h': height,
+            'w': width
+        }
+        res = self._post_json(
+            self._url('/exec/{0}/resize'.format(exec_id)), data
+        )
+        res.raise_for_status()
+
+    def exec_start(self, exec_id, detach=False, tty=False, stream=False):
+        if utils.compare_version('1.15', self._version) < 0:
+            raise errors.InvalidVersion('Exec is not supported in API < 1.15')
+        if isinstance(exec_id, dict):
+            exec_id = exec_id.get('Id')
+
+        data = {
+            'Tty': tty,
+            'Detach': detach
+        }
 
-        # start the command
-        cmd_id = res.json().get('Id')
-        res = self._post_json(self._url('/exec/{0}/start'.format(cmd_id)),
+        res = self._post_json(self._url('/exec/{0}/start'.format(exec_id)),
                               data=data, stream=stream)
         self._raise_for_status(res)
         if stream:


Test patch:
diff --git a/tests/fake_api.py b/tests/fake_api.py
--- a/tests/fake_api.py
+++ b/tests/fake_api.py
@@ -18,6 +18,7 @@
 
 FAKE_CONTAINER_ID = '3cc2351ab11b'
 FAKE_IMAGE_ID = 'e9aa60c60128'
+FAKE_EXEC_ID = 'd5d177f121dc'
 FAKE_IMAGE_NAME = 'test_image'
 FAKE_TARBALL_PATH = '/path/to/tarball'
 FAKE_REPO_NAME = 'repo'
@@ -247,13 +248,13 @@ def get_fake_export():
     return status_code, response
 
 
-def post_fake_execute():
+def post_fake_exec_create():
     status_code = 200
-    response = {'Id': FAKE_CONTAINER_ID}
+    response = {'Id': FAKE_EXEC_ID}
     return status_code, response
 
 
-def post_fake_execute_start():
+def post_fake_exec_start():
     status_code = 200
     response = (b'\x01\x00\x00\x00\x00\x00\x00\x11bin\nboot\ndev\netc\n'
                 b'\x01\x00\x00\x00\x00\x00\x00\x12lib\nmnt\nproc\nroot\n'
@@ -261,6 +262,30 @@ def post_fake_execute_start():
     return status_code, response
 
 
+def post_fake_exec_resize():
+    status_code = 201
+    return status_code, ''
+
+
+def get_fake_exec_inspect():
+    return 200, {
+        'OpenStderr': True,
+        'OpenStdout': True,
+        'Container': get_fake_inspect_container()[1],
+        'Running': False,
+        'ProcessConfig': {
+            'arguments': ['hello world'],
+            'tty': False,
+            'entrypoint': 'echo',
+            'privileged': False,
+            'user': ''
+        },
+        'ExitCode': 0,
+        'ID': FAKE_EXEC_ID,
+        'OpenStdin': False
+    }
+
+
 def post_fake_stop_container():
     status_code = 200
     response = {'Id': FAKE_CONTAINER_ID}
@@ -393,9 +418,14 @@ def get_fake_stats():
     '{1}/{0}/containers/3cc2351ab11b/export'.format(CURRENT_VERSION, prefix):
     get_fake_export,
     '{1}/{0}/containers/3cc2351ab11b/exec'.format(CURRENT_VERSION, prefix):
-    post_fake_execute,
-    '{1}/{0}/exec/3cc2351ab11b/start'.format(CURRENT_VERSION, prefix):
-    post_fake_execute_start,
+    post_fake_exec_create,
+    '{1}/{0}/exec/d5d177f121dc/start'.format(CURRENT_VERSION, prefix):
+    post_fake_exec_start,
+    '{1}/{0}/exec/d5d177f121dc/json'.format(CURRENT_VERSION, prefix):
+    get_fake_exec_inspect,
+    '{1}/{0}/exec/d5d177f121dc/resize'.format(CURRENT_VERSION, prefix):
+    post_fake_exec_resize,
+
     '{1}/{0}/containers/3cc2351ab11b/stats'.format(CURRENT_VERSION, prefix):
     get_fake_stats,
     '{1}/{0}/containers/3cc2351ab11b/stop'.format(CURRENT_VERSION, prefix):
diff --git a/tests/integration_test.py b/tests/integration_test.py
--- a/tests/integration_test.py
+++ b/tests/integration_test.py
@@ -1071,9 +1071,12 @@ def runTest(self):
         self.client.start(id)
         self.tmp_containers.append(id)
 
-        res = self.client.execute(id, ['echo', 'hello'])
+        res = self.client.exec_create(id, ['echo', 'hello'])
+        self.assertIn('Id', res)
+
+        exec_log = self.client.exec_start(res)
         expected = b'hello\n' if six.PY3 else 'hello\n'
-        self.assertEqual(res, expected)
+        self.assertEqual(exec_log, expected)
 
 
 @unittest.skipIf(not EXEC_DRIVER_IS_NATIVE, 'Exec driver not native')
@@ -1085,9 +1088,12 @@ def runTest(self):
         self.client.start(id)
         self.tmp_containers.append(id)
 
-        res = self.client.execute(id, 'echo hello world', stdout=True)
+        res = self.client.exec_create(id, 'echo hello world')
+        self.assertIn('Id', res)
+
+        exec_log = self.client.exec_start(res)
         expected = b'hello world\n' if six.PY3 else 'hello world\n'
-        self.assertEqual(res, expected)
+        self.assertEqual(exec_log, expected)
 
 
 @unittest.skipIf(not EXEC_DRIVER_IS_NATIVE, 'Exec driver not native')
@@ -1099,14 +1105,33 @@ def runTest(self):
         self.client.start(id)
         self.tmp_containers.append(id)
 
-        chunks = self.client.execute(id, ['echo', 'hello\nworld'], stream=True)
+        exec_id = self.client.exec_create(id, ['echo', 'hello\nworld'])
+        self.assertIn('Id', exec_id)
+
         res = b'' if six.PY3 else ''
-        for chunk in chunks:
+        for chunk in self.client.exec_start(exec_id, stream=True):
             res += chunk
         expected = b'hello\nworld\n' if six.PY3 else 'hello\nworld\n'
         self.assertEqual(res, expected)
 
 
+@unittest.skipIf(not EXEC_DRIVER_IS_NATIVE, 'Exec driver not native')
+class TestExecInspect(BaseTestCase):
+    def runTest(self):
+        container = self.client.create_container('busybox', 'cat',
+                                                 detach=True, stdin_open=True)
+        id = container['Id']
+        self.client.start(id)
+        self.tmp_containers.append(id)
+
+        exec_id = self.client.exec_create(id, ['mkdir', '/does/not/exist'])
+        self.assertIn('Id', exec_id)
+        self.client.exec_start(exec_id)
+        exec_info = self.client.exec_inspect(exec_id)
+        self.assertIn('ExitCode', exec_info)
+        self.assertNotEqual(exec_info['ExitCode'], 0)
+
+
 class TestRunContainerStreaming(BaseTestCase):
     def runTest(self):
         container = self.client.create_container('busybox', '/bin/sh',
diff --git a/tests/test.py b/tests/test.py
--- a/tests/test.py
+++ b/tests/test.py
@@ -1583,31 +1583,95 @@ def test_stop_container_with_dict_instead_of_id(self):
             timeout=(docker.client.DEFAULT_TIMEOUT_SECONDS + timeout)
         )
 
-    def test_execute_command(self):
+    def test_exec_create(self):
         try:
-            self.client.execute(fake_api.FAKE_CONTAINER_ID, ['ls', '-1'])
+            self.client.exec_create(fake_api.FAKE_CONTAINER_ID, ['ls', '-1'])
         except Exception as e:
             self.fail('Command should not raise exception: {0}'.format(e))
 
         args = fake_request.call_args
-        self.assertEqual(args[0][0],
-                         url_prefix + 'exec/3cc2351ab11b/start')
+        self.assertEqual(
+            args[0][0], url_prefix + 'containers/{0}/exec'.format(
+                fake_api.FAKE_CONTAINER_ID
+            )
+        )
 
-        self.assertEqual(json.loads(args[1]['data']),
-                         json.loads('''{
-                            "Tty": false,
-                            "AttachStderr": true,
-                            "Container": "3cc2351ab11b",
-                            "Cmd": ["ls", "-1"],
-                            "AttachStdin": false,
-                            "User": "",
-                            "Detach": false,
-                            "Privileged": false,
-                            "AttachStdout": true}'''))
+        self.assertEqual(
+            json.loads(args[1]['data']), {
+                'Tty': False,
+                'AttachStdout': True,
+                'Container': fake_api.FAKE_CONTAINER_ID,
+                'Cmd': ['ls', '-1'],
+                'Privileged': False,
+                'AttachStdin': False,
+                'AttachStderr': True,
+                'User': ''
+            }
+        )
 
         self.assertEqual(args[1]['headers'],
                          {'Content-Type': 'application/json'})
 
+    def test_exec_start(self):
+        try:
+            self.client.exec_start(fake_api.FAKE_EXEC_ID)
+        except Exception as e:
+            self.fail('Command should not raise exception: {0}'.format(e))
+
+        args = fake_request.call_args
+        self.assertEqual(
+            args[0][0], url_prefix + 'exec/{0}/start'.format(
+                fake_api.FAKE_EXEC_ID
+            )
+        )
+
+        self.assertEqual(
+            json.loads(args[1]['data']), {
+                'Tty': False,
+                'Detach': False,
+            }
+        )
+
+        self.assertEqual(args[1]['headers'],
+                         {'Content-Type': 'application/json'})
+
+    def test_exec_inspect(self):
+        try:
+            self.client.exec_inspect(fake_api.FAKE_EXEC_ID)
+        except Exception as e:
+            self.fail('Command should not raise exception: {0}'.format(e))
+
+        args = fake_request.call_args
+        self.assertEqual(
+            args[0][0], url_prefix + 'exec/{0}/json'.format(
+                fake_api.FAKE_EXEC_ID
+            )
+        )
+
+    def test_exec_resize(self):
+        try:
+            self.client.exec_resize(fake_api.FAKE_EXEC_ID, height=20, width=60)
+        except Exception as e:
+            self.fail('Command should not raise exception: {0}'.format(e))
+
+        args = fake_request.call_args
+        self.assertEqual(
+            args[0][0], url_prefix + 'exec/{0}/resize'.format(
+                fake_api.FAKE_EXEC_ID
+            )
+        )
+
+        self.assertEqual(
+            json.loads(args[1]['data']), {
+                'h': 20,
+                'w': 60,
+            }
+        )
+
+        self.assertEqual(
+            args[1]['headers'], {'Content-Type': 'application/json'}
+        )
+
     def test_pause_container(self):
         try:
             self.client.pause(fake_api.FAKE_CONTAINER_ID)