Repository: jschneier/django-storages

Issue:
Unclosed SFTP connection
Consider a one-off use of `SFTPStorage` as you might for example see in a Celery task:

```py
from storages.backends.sftpstorage import SFTPStorage

SFTPStorage(...).listdir("")
```

Unfortunately, the underlying SSH connection might stay open and continue using resources. [Paramiko's documentation of `close`](https://docs.paramiko.org/en/latest/api/client.html#paramiko.client.SSHClient.close) warns about this:

> Paramiko registers garbage collection hooks that will try to automatically close connections for you, but this is not presently reliable. Failure to explicitly close your client after use may lead to end-of-process hangs!

Workaround:

```py
from paramiko.util import ClosingContextManager
from storages.backends.sftpstorage import SFTPStorage

class ClosingSFTPStorage(SFTPStorage, ClosingContextManager):
    def close():
        self._ssh.close()

with ClosingSFTPStorage(...) as sftp:
    sftp.listdir("")
```


Discussion:
Cool good find. Can you open a PR that includes that change into the base `SFTPStorage` and then add that snippet to the docs with a warning.

Resolving patch:
diff --git a/storages/backends/sftpstorage.py b/storages/backends/sftpstorage.py
--- a/storages/backends/sftpstorage.py
+++ b/storages/backends/sftpstorage.py
@@ -15,6 +15,7 @@
 import paramiko
 from django.core.files.base import File
 from django.utils.deconstruct import deconstructible
+from paramiko.util import ClosingContextManager
 
 from storages.base import BaseStorage
 from storages.utils import is_seekable
@@ -22,7 +23,7 @@
 
 
 @deconstructible
-class SFTPStorage(BaseStorage):
+class SFTPStorage(ClosingContextManager, BaseStorage):
     def __init__(self, **settings):
         super().__init__(**settings)
         self._host = self.host
@@ -35,6 +36,7 @@ def __init__(self, **settings):
         self._known_host_file = self.known_host_file
         self._root_path = self.root_path
         self._base_url = self.base_url
+        self._ssh = None
         self._sftp = None
 
     def get_default_settings(self):
@@ -81,6 +83,11 @@ def _connect(self):
         if self._ssh.get_transport():
             self._sftp = self._ssh.open_sftp()
 
+    def close(self):
+        if self._ssh is None:
+            return
+        self._ssh.close()
+
     @property
     def sftp(self):
         """Lazy SFTP connection"""


Test patch:
diff --git a/tests/test_sftp.py b/tests/test_sftp.py
--- a/tests/test_sftp.py
+++ b/tests/test_sftp.py
@@ -40,6 +40,18 @@ def test_connect(self, mock_ssh):
         self.storage._connect()
         self.assertEqual('foo', mock_ssh.return_value.connect.call_args[0][0])
 
+    @patch('paramiko.SSHClient')
+    def test_close_unopened(self, mock_ssh):
+        with self.storage:
+            pass
+        mock_ssh.return_value.close.assert_not_called()
+
+    @patch('paramiko.SSHClient')
+    def test_close_opened(self, mock_ssh):
+        with self.storage as storage:
+            storage._connect()
+        mock_ssh.return_value.close.assert_called_once_with()
+
     def test_open(self):
         file_ = self.storage._open('foo')
         self.assertIsInstance(file_, sftpstorage.SFTPStorageFile)