Repository: docker/docker-py

Issue:
Support "Get service logs"
Support [Get Service logs `/v1.25/services/{id}/logs`](https://docs.docker.com/engine/api/v1.25/#operation/ServiceLogs) API call.
When working with swarm this is by far the best feature of 1.25


Discussion:

Resolving patch:
diff --git a/docker/api/service.py b/docker/api/service.py
--- a/docker/api/service.py
+++ b/docker/api/service.py
@@ -166,6 +166,56 @@ def services(self, filters=None):
         url = self._url('/services')
         return self._result(self._get(url, params=params), True)
 
+    @utils.minimum_version('1.25')
+    @utils.check_resource
+    def service_logs(self, service, details=False, follow=False, stdout=False,
+                     stderr=False, since=0, timestamps=False, tail='all',
+                     is_tty=None):
+        """
+            Get log stream for a service.
+            Note: This endpoint works only for services with the ``json-file``
+            or ``journald`` logging drivers.
+
+            Args:
+                service (str): ID or name of the service
+                details (bool): Show extra details provided to logs.
+                    Default: ``False``
+                follow (bool): Keep connection open to read logs as they are
+                    sent by the Engine. Default: ``False``
+                stdout (bool): Return logs from ``stdout``. Default: ``False``
+                stderr (bool): Return logs from ``stderr``. Default: ``False``
+                since (int): UNIX timestamp for the logs staring point.
+                    Default: 0
+                timestamps (bool): Add timestamps to every log line.
+                tail (string or int): Number of log lines to be returned,
+                    counting from the current end of the logs. Specify an
+                    integer or ``'all'`` to output all log lines.
+                    Default: ``all``
+                is_tty (bool): Whether the service's :py:class:`ContainerSpec`
+                    enables the TTY option. If omitted, the method will query
+                    the Engine for the information, causing an additional
+                    roundtrip.
+
+            Returns (generator): Logs for the service.
+        """
+        params = {
+            'details': details,
+            'follow': follow,
+            'stdout': stdout,
+            'stderr': stderr,
+            'since': since,
+            'timestamps': timestamps,
+            'tail': tail
+        }
+
+        url = self._url('/services/{0}/logs', service)
+        res = self._get(url, params=params, stream=True)
+        if is_tty is None:
+            is_tty = self.inspect_service(
+                service
+            )['Spec']['TaskTemplate']['ContainerSpec'].get('TTY', False)
+        return self._get_result_tty(True, res, is_tty)
+
     @utils.minimum_version('1.24')
     def tasks(self, filters=None):
         """
diff --git a/docker/models/services.py b/docker/models/services.py
--- a/docker/models/services.py
+++ b/docker/models/services.py
@@ -77,6 +77,34 @@ def update(self, **kwargs):
             **create_kwargs
         )
 
+    def logs(self, **kwargs):
+        """
+            Get log stream for the service.
+            Note: This method works only for services with the ``json-file``
+            or ``journald`` logging drivers.
+
+            Args:
+                details (bool): Show extra details provided to logs.
+                    Default: ``False``
+                follow (bool): Keep connection open to read logs as they are
+                    sent by the Engine. Default: ``False``
+                stdout (bool): Return logs from ``stdout``. Default: ``False``
+                stderr (bool): Return logs from ``stderr``. Default: ``False``
+                since (int): UNIX timestamp for the logs staring point.
+                    Default: 0
+                timestamps (bool): Add timestamps to every log line.
+                tail (string or int): Number of log lines to be returned,
+                    counting from the current end of the logs. Specify an
+                    integer or ``'all'`` to output all log lines.
+                    Default: ``all``
+
+            Returns (generator): Logs for the service.
+        """
+        is_tty = self.attrs['Spec']['TaskTemplate']['ContainerSpec'].get(
+            'TTY', False
+        )
+        return self.client.api.service_logs(self.id, is_tty=is_tty, **kwargs)
+
 
 class ServiceCollection(Collection):
     """Services on the Docker server."""


Test patch:
diff --git a/tests/helpers.py b/tests/helpers.py
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -1,3 +1,4 @@
+import functools
 import os
 import os.path
 import random
@@ -53,6 +54,15 @@ def requires_api_version(version):
     )
 
 
+def requires_experimental(f):
+    @functools.wraps(f)
+    def wrapped(self, *args, **kwargs):
+        if not self.client.info()['ExperimentalBuild']:
+            pytest.skip('Feature requires Docker Engine experimental mode')
+        return f(self, *args, **kwargs)
+    return wrapped
+
+
 def wait_on_condition(condition, delay=0.1, timeout=40):
     start_time = time.time()
     while not condition():
diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py
--- a/tests/integration/api_service_test.py
+++ b/tests/integration/api_service_test.py
@@ -4,8 +4,11 @@
 import time
 
 import docker
+import six
 
-from ..helpers import force_leave_swarm, requires_api_version
+from ..helpers import (
+    force_leave_swarm, requires_api_version, requires_experimental
+)
 from .base import BaseAPIIntegrationTest, BUSYBOX
 
 
@@ -27,13 +30,15 @@ def tearDown(self):
     def get_service_name(self):
         return 'dockerpytest_{0:x}'.format(random.getrandbits(64))
 
-    def get_service_container(self, service_name, attempts=20, interval=0.5):
+    def get_service_container(self, service_name, attempts=20, interval=0.5,
+                              include_stopped=False):
         # There is some delay between the service's creation and the creation
         # of the service's containers. This method deals with the uncertainty
         # when trying to retrieve the container associated with a service.
         while True:
             containers = self.client.containers(
-                filters={'name': [service_name]}, quiet=True
+                filters={'name': [service_name]}, quiet=True,
+                all=include_stopped
             )
             if len(containers) > 0:
                 return containers[0]
@@ -97,6 +102,20 @@ def test_create_service_simple(self):
         assert len(services) == 1
         assert services[0]['ID'] == svc_id['ID']
 
+    @requires_api_version('1.25')
+    @requires_experimental
+    def test_service_logs(self):
+        name, svc_id = self.create_simple_service()
+        assert self.get_service_container(name, include_stopped=True)
+        logs = self.client.service_logs(svc_id, stdout=True, is_tty=False)
+        log_line = next(logs)
+        if six.PY3:
+            log_line = log_line.decode('utf-8')
+        assert 'hello\n' in log_line
+        assert 'com.docker.swarm.service.id={}'.format(
+            svc_id['ID']
+        ) in log_line
+
     def test_create_service_custom_log_driver(self):
         container_spec = docker.types.ContainerSpec(
             BUSYBOX, ['echo', 'hello']