Repository: imageio/imageio

Issue:
UnsupportedOperation seek while reading GIF from HTTP uri
Related to:
https://github.com/imageio/imageio/issues/202
https://github.com/imageio/imageio/issues/238
https://github.com/imageio/imageio/issues/335

Reading GIF images from a HTTP fails with `"UnsupportedOperation: seek"`

For example, try running:
```im = imageio.mimread('http://media.giphy.com/media/3oD3YveOJWdwIAfZ5e/giphy.gif')```

Python 3.7.6
Imageio 2.6.1
Pillow 7.0.0

Discussion:
almarklein: Removed old mechanism. Tests are failing in a few places, as expected. Now to put in the new mechanism.
coveralls: 
[![Coverage Status](https://coveralls.io/builds/28809835/badge)](https://coveralls.io/builds/28809835)

Coverage decreased (-0.2%) to 89.259% when pulling **3e161056c3d314ed361502b5b8005a99bbc48691 on seek** into **dc212e26c4fdd662f195c017e34f03b184ea6ab0 on master**.


Patch:
diff --git a/imageio/core/request.py b/imageio/core/request.py
index a4a68bf..dbd455b 100644
--- a/imageio/core/request.py
+++ b/imageio/core/request.py
@@ -340,12 +340,12 @@ class Request(object):
                 # Open zipfile and open new file object for specific file
                 self._zipfile = zipfile.ZipFile(filename, "r")
                 self._file = self._zipfile.open(name, "r")
-                make_file_object_support_noop_seeks(self._file)
+                self._file = SeekableFileObject(self._file)
 
         elif self._uri_type in [URI_HTTP or URI_FTP]:
             assert not want_to_write  # This should have been tested in init
             self._file = urlopen(self.filename, timeout=5)
-            make_file_object_support_noop_seeks(self._file)
+            self._file = SeekableFileObject(self._file)
 
         return self._file
 
@@ -482,33 +482,90 @@ def read_n_bytes(f, N):
     return bb
 
 
-def make_file_object_support_noop_seeks(f):
-    """This fixes up an HTTPResponse object so that it can tell(), and also
-    seek() will work if its effectively a no-op. This allows tools like Pillow
-    to use the file object.
+class SeekableFileObject:
+    """ A readonly wrapper file object that add support for seeking, even if
+    the wrapped file object does not. The allows us to stream from http and
+    still use Pillow.
     """
-    count = [0]
 
-    def read(n=None):
-        res = ori_read(n)
-        count[0] += len(res)
+    def __init__(self, f):
+        self.f = f
+        self._i = 0  # >=0 but can exceed buffer
+        self._buffer = b""
+        self._have_all = False
+        self.closed = False
+
+    def read(self, n=None):
+
+        # Fix up n
+        if n is None:
+            pass
+        else:
+            n = int(n)
+            if n < 0:
+                n = None
+
+        # Can and must we read more?
+        if not self._have_all:
+            more = b""
+            if n is None:
+                more = self.f.read()
+                self._have_all = True
+            else:
+                want_i = self._i + n
+                want_more = want_i - len(self._buffer)
+                if want_more > 0:
+                    more = self.f.read(want_more)
+                    if len(more) < want_more:
+                        self._have_all = True
+            self._buffer += more
+
+        # Read data from buffer and update pointer
+        if n is None:
+            res = self._buffer[self._i :]
+        else:
+            res = self._buffer[self._i : self._i + n]
+        self._i += len(res)
+
         return res
 
-    def tell():
-        return count[0]
+    def tell(self):
+        return self._i
+
+    def seek(self, i, mode=0):
+        # Mimic BytesIO behavior
+
+        # Get the absolute new position
+        i = int(i)
+        if mode == 0:
+            if i < 0:
+                raise ValueError("negative seek value " + str(i))
+            real_i = i
+        elif mode == 1:
+            real_i = max(0, self._i + i)  # negative ok here
+        elif mode == 2:
+            if not self._have_all:
+                self.read()
+            real_i = max(0, len(self._buffer) + i)
+        else:
+            raise ValueError("invalid whence (%s, should be 0, 1 or 2)" % i)
+
+        # Read some?
+        if real_i <= len(self._buffer):
+            pass  # no need to read
+        elif not self._have_all:
+            assert real_i > self._i  # if we don't have all, _i cannot be > _buffer
+            self.read(real_i - self._i)  # sets self._i
 
-    def seek(i, mode=0):
-        if not (mode == 0 and i == count[0]):
-            ori_seek(i, mode)
+        self._i = real_i
+        return self._i
 
-    def fail_seek(i, mode=0):
-        raise RuntimeError("No seeking allowed!")
+    def close(self):
+        self.closed = True
+        self.f.close()
 
-    # Note, there is currently no protection from wrapping an object more than
-    # once, it will (probably) work though, because closures.
-    ori_read = f.read
-    ori_seek = f.seek if hasattr(f, "seek") else fail_seek
+    def isatty(self):
+        return False
 
-    f.read = read
-    f.tell = tell
-    f.seek = seek
+    def seekable(self):
+        return True


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
index ca41011..73a9001 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1,14 +1,15 @@
 """ Test imageio core functionality.
 """
 
-import time
-import sys
 import os
+import sys
+import time
 import shutil
+import random
+import tempfile
 import ctypes.util
-from zipfile import ZipFile
 from io import BytesIO
-import tempfile
+from zipfile import ZipFile
 
 import numpy as np
 import pytest
@@ -306,6 +307,60 @@ def test_request_save_sources():
         assert res == bytes
 
 
+def test_request_seekable_file_object():
+    SeekableFileObject = imageio.core.request.SeekableFileObject
+    data = bytes([int(random.uniform(0, 255)) for i in range(100)])
+    f1 = BytesIO(data)
+    f2 = SeekableFileObject(f1)
+
+    # Test all kinds of seeks, reads and tells
+    resses = []
+    for f in (f1, f2):
+        (f1.seek(0), f2.seek(0))
+        res = [
+            f.read(8),
+            f.tell(),
+            f.seek(0),
+            f.tell(),
+            f.read(8),
+            f.read(),
+            f.seek(20, 1),
+            f.read(8),
+            f.seek(10, 2),
+            f.read(8),
+            f.seek(-10, 2),
+            f.read(8),
+            f.seek(-20, 2),
+            f.tell(),
+            f.read(8),
+            f.read(0),
+            f.read(-1),
+        ]
+        resses.append(res)
+    assert resses[0] == resses[1]
+
+    # Test out of bounds
+    for f in (f1, f2):
+        # Align them
+        (f1.seek(0), f2.seek(0))
+        f.seek(3)
+        assert f.tell() == 3
+        with raises(ValueError):
+            f.seek(-10)
+        assert f.tell() == 3
+        f.seek(-10, 1)  # no raise!
+        assert f.tell() == 0
+        f.seek(-10, 2)
+        assert f.tell() == 90
+        f.seek(10, 2)
+        assert f.tell() == 110
+        assert f.read(1) == b""
+        f.seek(20, 1)
+        assert f.tell() == 130
+        f.seek(150)
+        assert f.tell() == 150
+
+
 def test_request_file_no_seek():
     class File:
         def read(self, n):