Repository: jschneier/django-storages

Issue:
`closed` is True right after opening a `S3File`
Hey there!

### Description

After updating to 1.14 I encountered the following issue:

```python
storage = S3Storage(bucket_name="my-bucket")
file = storage.open("my-file")
file.closed
> True  # Should be False
```

### Analysis

I pinpointed the issue to commit https://github.com/jschneier/django-storages/commit/af91e2ca2d7f7f77541701a499fd4ef5065d0141.

The issue it fixes is perfectly legitimate but the way it fixes it introduces this bug.

`S3File` implements some kind of lazy opening by leveraging the `file` property (and its `_get_file` accessor). The file is not actually opened until the `file` property is first accessed, `self._file` remains `None` until then.
With the current `closed` implementation, `self._file` being `None` results in `closed` returning `False` even though in this case the file was never closed, it was never even read from.


### Proposed fixes

I see two approaches:
- keeping track of whether a `S3File` is opened of closed through a `_closed` attribute (for example), setting it to `True` in `S3File.__init__`, to `False` in `S3File.close` and reading it in `S3File.closed`
- forcing `_file` to be defined by calling `f.file` in `S3Storage._open`

I have no idea which is better (might be another approach altogether).


NB: might be related to https://github.com/jschneier/django-storages/issues/1308


Discussion:
Thanks for the comprehensive report, will look into this

Resolving patch:
diff --git a/storages/backends/s3.py b/storages/backends/s3.py
--- a/storages/backends/s3.py
+++ b/storages/backends/s3.py
@@ -132,6 +132,7 @@ def __init__(self, name, mode, storage, buffer_size=None):
             self.obj.load(**params)
         self._is_dirty = False
         self._raw_bytes_written = 0
+        self._closed = False
         self._file = None
         self._multipart = None
         self._parts = None
@@ -148,7 +149,7 @@ def size(self):
 
     @property
     def closed(self):
-        return not self._file or self._file.closed
+        return self._closed
 
     def _get_file(self):
         if self._file is None:
@@ -168,6 +169,7 @@ def _get_file(self):
                 self._file.seek(0)
                 if self._storage.gzip and self.obj.content_encoding == "gzip":
                     self._file = self._decompress_file(mode=self._mode, file=self._file)
+            self._closed = False
         return self._file
 
     def _set_file(self, value):
@@ -262,6 +264,7 @@ def close(self):
         if self._file is not None:
             self._file.close()
             self._file = None
+        self._closed = True
 
 
 @deconstructible


Test patch:
diff --git a/tests/test_s3.py b/tests/test_s3.py
--- a/tests/test_s3.py
+++ b/tests/test_s3.py
@@ -904,18 +904,22 @@ def test_loading_ssec(self):
     def test_closed(self):
         f = s3.S3File("test", "wb", self.storage)
 
-        with self.subTest("is True after init"):
-            self.assertTrue(f.closed)
+        with self.subTest("after init"):
+            self.assertFalse(f.closed)
 
-        with self.subTest("is False after file access"):
+        with self.subTest("after file access"):
             # Ensure _get_file has been called
             f.file
             self.assertFalse(f.closed)
 
-        with self.subTest("is True after close"):
+        with self.subTest("after close"):
             f.close()
             self.assertTrue(f.closed)
 
+        with self.subTest("reopening"):
+            f.file
+            self.assertFalse(f.closed)
+
 
 @mock_s3
 class S3StorageTestsWithMoto(TestCase):