Repository: docker/docker-py

Issue:
Timeouts don't work on windows
Currently the windows npipe implementation doesn't honour timeouts. Regardless of which api endpoint you use or pretty much anything else this leads to bugs where the docker api waits until the docker daemon finishes instead of timing out properly.

For example, if there is a dockerfile containing at `timeout/`
```
FROM alpine

RUN sleep 1000
```
and you run
```python
from docker import DockerClient

DockerClient.from_env().images.build(path="timeout/", timeout=3)
```
python will hang for the full 1000 seconds instead of raising an error after 3.

Version info:  
docker-py: 6.0.1  
python: 3.11.3  
docker:
Client:
 Cloud integration: v1.0.24
 Version:           20.10.14
 API version:       1.41
 Go version:        go1.16.15
 Git commit:        a224086
 Built:             Thu Mar 24 01:53:11 2022        
 OS/Arch:           windows/amd64
 Context:           default
 Experimental:      true

Server: Docker Desktop 4.8.1 (78998)
 Engine:
  Version:          20.10.14
  API version:      1.41 (minimum version 1.12)     
  Go version:       go1.16.15
  Git commit:       87a90dc
  Built:            Thu Mar 24 01:46:14 2022        
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.5.11
  GitCommit:        3df54a852345ae127d1fa3092b95168e4a88e2f8
 runc:
  Version:          1.0.3
  GitCommit:        v1.0.3-0-gf46b6ba
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

Discussion:

Patch:
diff --git a/docker/api/container.py b/docker/api/container.py
index 9a25b214..40607e79 100644
--- a/docker/api/container.py
+++ b/docker/api/container.py
@@ -678,7 +678,8 @@ class ContainerApiMixin:
             container (str): The container to diff
 
         Returns:
-            (str)
+            (list) A list of dictionaries containing the attributes `Path`
+                and `Kind`.
 
         Raises:
             :py:class:`docker.errors.APIError`
@@ -1163,8 +1164,9 @@ class ContainerApiMixin:
                     'one_shot is only available in conjunction with '
                     'stream=False'
                 )
-            return self._stream_helper(self._get(url, params=params),
-                                       decode=decode)
+            return self._stream_helper(
+                self._get(url, stream=True, params=params), decode=decode
+            )
         else:
             if decode:
                 raise errors.InvalidArgument(
diff --git a/docker/models/containers.py b/docker/models/containers.py
index f451cf3f..2eeefda1 100644
--- a/docker/models/containers.py
+++ b/docker/models/containers.py
@@ -141,7 +141,8 @@ class Container(Model):
         Inspect changes on a container's filesystem.
 
         Returns:
-            (str)
+            (list) A list of dictionaries containing the attributes `Path`
+                and `Kind`.
 
         Raises:
             :py:class:`docker.errors.APIError`
diff --git a/docker/transport/npipeconn.py b/docker/transport/npipeconn.py
index 87033cf2..45988b2d 100644
--- a/docker/transport/npipeconn.py
+++ b/docker/transport/npipeconn.py
@@ -5,17 +5,13 @@ from docker.transport.basehttpadapter import BaseHTTPAdapter
 from .. import constants
 from .npipesocket import NpipeSocket
 
-import http.client as httplib
-
-try:
-    import requests.packages.urllib3 as urllib3
-except ImportError:
-    import urllib3
+import urllib3
+import urllib3.connection
 
 RecentlyUsedContainer = urllib3._collections.RecentlyUsedContainer
 
 
-class NpipeHTTPConnection(httplib.HTTPConnection):
+class NpipeHTTPConnection(urllib3.connection.HTTPConnection):
     def __init__(self, npipe_path, timeout=60):
         super().__init__(
             'localhost', timeout=timeout
diff --git a/docker/transport/npipesocket.py b/docker/transport/npipesocket.py
index 766372ae..9cbe40cc 100644
--- a/docker/transport/npipesocket.py
+++ b/docker/transport/npipesocket.py
@@ -4,6 +4,9 @@ import io
 
 import win32file
 import win32pipe
+import pywintypes
+import win32event
+import win32api
 
 cERROR_PIPE_BUSY = 0xe7
 cSECURITY_SQOS_PRESENT = 0x100000
@@ -54,7 +57,9 @@ class NpipeSocket:
                 0,
                 None,
                 win32file.OPEN_EXISTING,
-                cSECURITY_ANONYMOUS | cSECURITY_SQOS_PRESENT,
+                (cSECURITY_ANONYMOUS
+                    | cSECURITY_SQOS_PRESENT
+                    | win32file.FILE_FLAG_OVERLAPPED),
                 0
             )
         except win32pipe.error as e:
@@ -131,22 +136,37 @@ class NpipeSocket:
         if not isinstance(buf, memoryview):
             readbuf = memoryview(buf)
 
-        err, data = win32file.ReadFile(
-            self._handle,
-            readbuf[:nbytes] if nbytes else readbuf
-        )
-        return len(data)
-
-    def _recv_into_py2(self, buf, nbytes):
-        err, data = win32file.ReadFile(self._handle, nbytes or len(buf))
-        n = len(data)
-        buf[:n] = data
-        return n
+        event = win32event.CreateEvent(None, True, True, None)
+        try:
+            overlapped = pywintypes.OVERLAPPED()
+            overlapped.hEvent = event
+            err, data = win32file.ReadFile(
+                self._handle,
+                readbuf[:nbytes] if nbytes else readbuf,
+                overlapped
+            )
+            wait_result = win32event.WaitForSingleObject(event, self._timeout)
+            if wait_result == win32event.WAIT_TIMEOUT:
+                win32file.CancelIo(self._handle)
+                raise TimeoutError
+            return win32file.GetOverlappedResult(self._handle, overlapped, 0)
+        finally:
+            win32api.CloseHandle(event)
 
     @check_closed
     def send(self, string, flags=0):
-        err, nbytes = win32file.WriteFile(self._handle, string)
-        return nbytes
+        event = win32event.CreateEvent(None, True, True, None)
+        try:
+            overlapped = pywintypes.OVERLAPPED()
+            overlapped.hEvent = event
+            win32file.WriteFile(self._handle, string, overlapped)
+            wait_result = win32event.WaitForSingleObject(event, self._timeout)
+            if wait_result == win32event.WAIT_TIMEOUT:
+                win32file.CancelIo(self._handle)
+                raise TimeoutError
+            return win32file.GetOverlappedResult(self._handle, overlapped, 0)
+        finally:
+            win32api.CloseHandle(event)
 
     @check_closed
     def sendall(self, string, flags=0):
@@ -165,15 +185,12 @@ class NpipeSocket:
     def settimeout(self, value):
         if value is None:
             # Blocking mode
-            self._timeout = win32pipe.NMPWAIT_WAIT_FOREVER
+            self._timeout = win32event.INFINITE
         elif not isinstance(value, (float, int)) or value < 0:
             raise ValueError('Timeout value out of range')
-        elif value == 0:
-            # Non-blocking mode
-            self._timeout = win32pipe.NMPWAIT_NO_WAIT
         else:
             # Timeout mode - Value converted to milliseconds
-            self._timeout = value * 1000
+            self._timeout = int(value * 1000)
 
     def gettimeout(self):
         return self._timeout
diff --git a/docker/transport/sshconn.py b/docker/transport/sshconn.py
index 7421f33b..a92beb62 100644
--- a/docker/transport/sshconn.py
+++ b/docker/transport/sshconn.py
@@ -11,12 +11,8 @@ import subprocess
 from docker.transport.basehttpadapter import BaseHTTPAdapter
 from .. import constants
 
-import http.client as httplib
-
-try:
-    import requests.packages.urllib3 as urllib3
-except ImportError:
-    import urllib3
+import urllib3
+import urllib3.connection
 
 RecentlyUsedContainer = urllib3._collections.RecentlyUsedContainer
 
@@ -99,7 +95,7 @@ class SSHSocket(socket.socket):
         self.proc.terminate()
 
 
-class SSHConnection(httplib.HTTPConnection):
+class SSHConnection(urllib3.connection.HTTPConnection):
     def __init__(self, ssh_transport=None, timeout=60, host=None):
         super().__init__(
             'localhost', timeout=timeout
diff --git a/docker/transport/ssladapter.py b/docker/transport/ssladapter.py
index 6aa80037..69274bd1 100644
--- a/docker/transport/ssladapter.py
+++ b/docker/transport/ssladapter.py
@@ -7,10 +7,7 @@ from requests.adapters import HTTPAdapter
 
 from docker.transport.basehttpadapter import BaseHTTPAdapter
 
-try:
-    import requests.packages.urllib3 as urllib3
-except ImportError:
-    import urllib3
+import urllib3
 
 
 PoolManager = urllib3.poolmanager.PoolManager
diff --git a/docker/transport/unixconn.py b/docker/transport/unixconn.py
index 1b00762a..fae10f26 100644
--- a/docker/transport/unixconn.py
+++ b/docker/transport/unixconn.py
@@ -1,20 +1,17 @@
 import requests.adapters
 import socket
-import http.client as httplib
 
 from docker.transport.basehttpadapter import BaseHTTPAdapter
 from .. import constants
 
-try:
-    import requests.packages.urllib3 as urllib3
-except ImportError:
-    import urllib3
+import urllib3
+import urllib3.connection
 
 
 RecentlyUsedContainer = urllib3._collections.RecentlyUsedContainer
 
 
-class UnixHTTPConnection(httplib.HTTPConnection):
+class UnixHTTPConnection(urllib3.connection.HTTPConnection):
 
     def __init__(self, base_url, unix_socket, timeout=60):
         super().__init__(
@@ -30,12 +27,6 @@ class UnixHTTPConnection(httplib.HTTPConnection):
         sock.connect(self.unix_socket)
         self.sock = sock
 
-    def putheader(self, header, *values):
-        super().putheader(header, *values)
-
-    def response_class(self, sock, *args, **kwargs):
-        return httplib.HTTPResponse(sock, *args, **kwargs)
-
 
 class UnixHTTPConnectionPool(urllib3.connectionpool.HTTPConnectionPool):
     def __init__(self, base_url, socket_path, timeout=60, maxsize=10):
diff --git a/docker/types/daemon.py b/docker/types/daemon.py
index 10e81014..096b2cc1 100644
--- a/docker/types/daemon.py
+++ b/docker/types/daemon.py
@@ -1,9 +1,6 @@
 import socket
 
-try:
-    import requests.packages.urllib3 as urllib3
-except ImportError:
-    import urllib3
+import urllib3
 
 from ..errors import DockerException
 


Test patch:
diff --git a/tests/unit/api_container_test.py b/tests/unit/api_container_test.py
index c605da37..c4e2250b 100644
--- a/tests/unit/api_container_test.py
+++ b/tests/unit/api_container_test.py
@@ -1528,10 +1528,21 @@ class ContainerTest(BaseAPIClientTest):
         fake_request.assert_called_with(
             'GET',
             url_prefix + 'containers/' + fake_api.FAKE_CONTAINER_ID + '/stats',
+            stream=True,
             timeout=60,
             params={'stream': True}
         )
 
+    def test_container_stats_without_streaming(self):
+        self.client.stats(fake_api.FAKE_CONTAINER_ID, stream=False)
+
+        fake_request.assert_called_with(
+            'GET',
+            url_prefix + 'containers/' + fake_api.FAKE_CONTAINER_ID + '/stats',
+            timeout=60,
+            params={'stream': False}
+        )
+
     def test_container_stats_with_one_shot(self):
         self.client.stats(
             fake_api.FAKE_CONTAINER_ID, stream=False, one_shot=True)
diff --git a/tests/unit/api_test.py b/tests/unit/api_test.py
index a2348f08..4b6099c9 100644
--- a/tests/unit/api_test.py
+++ b/tests/unit/api_test.py
@@ -16,9 +16,9 @@ import http.server
 import docker
 import pytest
 import requests
+import urllib3
 from docker.api import APIClient
 from docker.constants import DEFAULT_DOCKER_API_VERSION
-from requests.packages import urllib3
 from unittest import mock
 
 from . import fake_api