Repository: python-pillow/Pillow

Issue:
Some image files are closed overeagerly (.mpo in this case)
### What did you do?

Upload an MPO file into a Django installation.

### What did you expect to happen?

I expected the image to either be saved or rejected.

### What actually happened?

Django crashed with a `FileNotFoundError` because the file was closed too early for some reason. I'm not sure anymore and I can't find anything relating to this anymore but I think that Pillow opens files with several frames differently (?) and this might cause the crash.

### What are your OS, Python and Pillow versions?

* OS: Ubuntu 18.04 LTS
* Python: 3.5.2 and 3.6.7
* Pillow: 5.4.1

https://github.com/matthiask/mpo-file-test

Here's a repository with an unit test hopefully demonstrating the issue. 

The problem only appears when using django-imagefield because django-imagefield not only verifies the image header but actually creates a scaled version of uploaded images. Some images' headers are fine but because of missing data e.g. at then end resizing still crashes. django-imagefield validates images a bit more but it also has to read the image data for this.

It's probably just an inconsistency in the way file pointers are handled. The `MpoImageFile` has a line setting `self.__fp = self.fp` and later the file is closed only iff `self.__fp == self.fp` (which is always true for some files but not e.g. for JPEG) meaning that Pillow closes a file handle it does not own.

The problem is circumvented for now in django-imagefield by working on a throwaway copy of the file (https://github.com/matthiask/django-imagefield/commit/567c4c235afa0fa3b9474c8099d97bc71dd89335)


cc @radarhere and @hugovk as requested here: https://github.com/python-pillow/Pillow/commit/71b64dcf7317495f413876f94175ada5eb7bb34d#commitcomment-32457451



Discussion:
Thanks for your sample code. I've looked into it and come up with a Pillow-only test case.

```python
from PIL import Image

def action(fp):
    im = Image.open(fp)
    im.load()

fp = open("Tests/images/frozenpond.mpo", "rb")
action(fp)
if fp.closed:
    print("fp was closed")
```
Ah yes, of course. This is a much better test case. Thanks!
I've created PR #3683 to resolve this.

Resolving patch:
diff --git a/src/PIL/Image.py b/src/PIL/Image.py
--- a/src/PIL/Image.py
+++ b/src/PIL/Image.py
@@ -578,11 +578,11 @@ def __enter__(self):
         return self
 
     def __exit__(self, *args):
-        if hasattr(self, "_close__fp"):
-            self._close__fp()
-        if (hasattr(self, 'fp') and hasattr(self, '_exclusive_fp')
-           and self.fp and self._exclusive_fp):
-            self.fp.close()
+        if hasattr(self, 'fp') and getattr(self, '_exclusive_fp', False):
+            if hasattr(self, "_close__fp"):
+                self._close__fp()
+            if self.fp:
+                self.fp.close()
         self.fp = None
 
     def close(self):
diff --git a/src/PIL/WebPImagePlugin.py b/src/PIL/WebPImagePlugin.py
--- a/src/PIL/WebPImagePlugin.py
+++ b/src/PIL/WebPImagePlugin.py
@@ -186,7 +186,7 @@ def _save_all(im, fp, filename):
     # will preserve non-alpha modes
     total = 0
     for ims in [im]+append_images:
-        total += 1 if not hasattr(ims, "n_frames") else ims.n_frames
+        total += getattr(ims, "n_frames", 1)
     if total == 1:
         _save(im, fp, filename)
         return
@@ -254,10 +254,7 @@ def _save_all(im, fp, filename):
     try:
         for ims in [im]+append_images:
             # Get # of frames in this image
-            if not hasattr(ims, "n_frames"):
-                nfr = 1
-            else:
-                nfr = ims.n_frames
+            nfr = getattr(ims, "n_frames", 1)
 
             for idx in range(nfr):
                 ims.seek(idx)


Test patch:
diff --git a/Tests/test_image.py b/Tests/test_image.py
--- a/Tests/test_image.py
+++ b/Tests/test_image.py
@@ -532,6 +532,18 @@ def test_no_resource_warning_on_save(self):
         with Image.open(test_file) as im:
             self.assert_warning(None, im.save, temp_file)
 
+    def test_load_on_nonexclusive_multiframe(self):
+        with open("Tests/images/frozenpond.mpo", "rb") as fp:
+            def act(fp):
+                im = Image.open(fp)
+                im.load()
+            act(fp)
+
+            with Image.open(fp) as im:
+                im.load()
+
+            self.assertFalse(fp.closed)
+
 
 class MockEncoder(object):
     pass