Repository: iterative/dvc

Issue:
dvc.api.open: io.UnsupportedOperation: seek with joblib.load
## Bug Report

Reproducible script:
```bash
#! /bin/bash

cd "$(mktemp -d)"
git init && dvc init

pip install moto[server]
moto_server s3 &> /dev/null &

export AWS_ACCESS_KEY_ID='testing' AWS_SECRET_ACCESS_KEY='testing'
python -c '
import boto3

session = boto3.session.Session()
s3 = session.client("s3", endpoint_url="http://localhost:5000")
s3.create_bucket(Bucket="dvc-temp")
'

dvc remote add -d moto s3://dvc-temp
dvc remote modify moto endpointurl http://localhost:5000

python -c '
import joblib
joblib.dump("hello world", "model.save")
'
dvc add model.save
dvc push
git add -A && git commit -m "initial"
rm model.save .dvc/cache/** -rf

python -c '
import dvc.api, joblib

with dvc.api.open("model.save", mode="rb") as f:
  msg = joblib.load(f)
'

```


```python
with dvc.api.open("model.save", mode="rb") as f:
    ...:     x = joblib.load(f)
    ...: 
---------------------------------------------------------------------------
UnsupportedOperation                      Traceback (most recent call last)
<ipython-input-36-5d9cb3fe0cb1> in <module>
      1 with dvc.api.open("model.save", mode="rb") as f:
----> 2     x = joblib.load(f)
      3 

~/repos/iterative/dvc/.env/env38/lib/python3.8/site-packages/joblib/numpy_pickle.py in load(filename, mmap_mode)
    572         fobj = filename
    573         filename = getattr(fobj, 'name', '')
--> 574         with _read_fileobject(fobj, filename, mmap_mode) as fobj:
    575             obj = _unpickle(fobj)
    576     else:

/usr/lib/python3.8/contextlib.py in __enter__(self)
    111         del self.args, self.kwds, self.func
    112         try:
--> 113             return next(self.gen)
    114         except StopIteration:
    115             raise RuntimeError("generator didn't yield") from None

~/repos/iterative/dvc/.env/env38/lib/python3.8/site-packages/joblib/numpy_pickle_utils.py in _read_fileobject(fileobj, filename, mmap_mode)
    122     """
    123     # Detect if the fileobj contains compressed data.
--> 124     compressor = _detect_compressor(fileobj)
    125 
    126     if compressor == "compat":

~/repos/iterative/dvc/.env/env38/lib/python3.8/site-packages/joblib/numpy_pickle_utils.py in _detect_compressor(fileobj)
     71         # Fallback to seek if the fileobject is not peekable.
     72         first_bytes = fileobj.read(max_prefix_len)
---> 73         fileobj.seek(0)
     74 
     75     if first_bytes.startswith(_ZFILE_PREFIX):

UnsupportedOperation: seek
```




Discussion:

Resolving patch:
diff --git a/dvc/utils/stream.py b/dvc/utils/stream.py
--- a/dvc/utils/stream.py
+++ b/dvc/utils/stream.py
@@ -6,11 +6,14 @@ class IterStream(io.RawIOBase):
 
     def __init__(self, iterator):  # pylint: disable=super-init-not-called
         self.iterator = iterator
-        self.leftover = None
+        self.leftover = b""
 
     def readable(self):
         return True
 
+    def writable(self) -> bool:
+        return False
+
     # Python 3 requires only .readinto() method, it still uses other ones
     # under some circumstances and falls back if those are absent. Since
     # iterator already constructs byte strings for us, .readinto() is not the
@@ -38,8 +41,16 @@ def read1(self, n=-1):
 
         # Return an arbitrary number or bytes
         if n <= 0:
-            self.leftover = None
+            self.leftover = b""
             return chunk
 
         output, self.leftover = chunk[:n], chunk[n:]
         return output
+
+    def peek(self, n):
+        while len(self.leftover) < n:
+            try:
+                self.leftover += next(self.iterator)
+            except StopIteration:
+                break
+        return self.leftover[:n]


Test patch:
diff --git a/tests/test.p12 b/tests/test.p12
new file mode 100644
Binary files /dev/null and b/tests/test.p12 differ
diff --git a/tests/unit/remote/test.p12 b/tests/unit/remote/test.p12
new file mode 100644
Binary files /dev/null and b/tests/unit/remote/test.p12 differ
diff --git a/tests/unit/utils/test_http.py b/tests/unit/utils/test_http.py
--- a/tests/unit/utils/test_http.py
+++ b/tests/unit/utils/test_http.py
@@ -5,6 +5,16 @@
 from dvc.utils.http import open_url
 
 
+def read_nbytes(fd, num, mode="r"):
+    data = b"" if mode == "rb" else ""
+    while len(data) < num:
+        chunk = fd.read(num - len(data))
+        if not chunk:
+            break
+        data += chunk
+    return data
+
+
 def test_open_url(tmp_path, monkeypatch, http):
     # Simulate bad connection
     original_iter_content = requests.Response.iter_content
@@ -27,5 +37,20 @@ def bad_iter_content(self, *args, **kwargs):
 
     with open_url((http / "sample.txt").url) as fd:
         # Test various .read() variants
-        assert fd.read(len(text)) == text
+        assert read_nbytes(fd, len(text), mode="r") == text
+        assert fd.read() == text
+        assert fd.read() == ""
+
+
+def test_open_url_peek_rb(tmp_path, monkeypatch, http):
+    # Goes over seek feature in 'rb' mode
+    text = "0123456789" * (io.DEFAULT_BUFFER_SIZE // 10 + 1)
+    http.gen("sample.txt", text * 2)
+
+    with open_url((http / "sample.txt").url, mode="rb") as fd:
+        text = text.encode("utf8")
+        assert fd.peek(len(text)) == text
+        assert read_nbytes(fd, len(text), mode="rb") == text
+        assert fd.peek(len(text)) == text
         assert fd.read() == text
+        assert fd.read() == b""