Repository: btubbs/sseclient

Issue:
Spurious replacement characters (U+FFFD) in events
The unicode decoder state is not saved across SSEClient.__next__() calls. Thus if the buffer ends in the middle of a multi-byte encoding the decoder fails to decode the char on the next call.

Discussion:

Patch:
diff --git a/sseclient.py b/sseclient.py
index ee7e16d..26e1b83 100644
--- a/sseclient.py
+++ b/sseclient.py
@@ -55,6 +55,8 @@ class SSEClient(object):
         requester = self.session or requests
         self.resp = requester.get(self.url, stream=True, **self.requests_kwargs)
         self.resp_iterator = self.iter_content()
+        self.decoder = codecs.getincrementaldecoder(
+            self.resp.encoding)(errors='replace')
 
         # TODO: Ensure we're handling redirects.  Might also stick the 'origin'
         # attribute on Events like the Javascript spec requires.
@@ -85,14 +87,12 @@ class SSEClient(object):
         return self
 
     def __next__(self):
-        decoder = codecs.getincrementaldecoder(
-            self.resp.encoding)(errors='replace')
         while not self._event_complete():
             try:
                 next_chunk = next(self.resp_iterator)
                 if not next_chunk:
                     raise EOFError()
-                self.buf += decoder.decode(next_chunk)
+                self.buf += self.decoder.decode(next_chunk)
 
             except (StopIteration, requests.RequestException, EOFError, six.moves.http_client.IncompleteRead) as e:
                 print(e)


Test patch:
diff --git a/test_sseclient.py b/test_sseclient.py
index bb0d767..73cf7a3 100644
--- a/test_sseclient.py
+++ b/test_sseclient.py
@@ -213,10 +213,35 @@ def test_client_sends_cookies():
     s.cookies = RequestsCookieJar()
     s.cookies['foo'] = 'bar'
     with mock.patch('sseclient.requests.Session.send') as m:
+        m.return_value.encoding = "utf-8"
         sseclient.SSEClient('http://blah.com', session=s)
         prepared_request = m.call_args[0][0]
         assert prepared_request.headers['Cookie'] == 'foo=bar'
 
+@pytest.fixture
+def unicode_multibyte_responses(monkeypatch):
+    content = join_events(
+        E(data='ööööööööööööööööööööööööööööööööööööööööööööööööööööööööö', id='first', retry='2000', event='blah'),
+        E(data='äääääääääääääääääääääääääääääääääääääääääääääääääääääääää', id='second', retry='4000', event='blerg'),
+        E(data='üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü', id='third'),
+    )
+    fake_get = mock.Mock(return_value=FakeResponse(200, content))
+    monkeypatch.setattr(requests, 'get', fake_get)
+
+    yield
+
+    fake_get.assert_called_once_with(
+        'http://blah.com',
+        headers={'Accept': 'text/event-stream', 'Cache-Control': 'no-cache'},
+        stream=True)
+
+@pytest.mark.usefixtures("unicode_multibyte_responses")
+def test_multiple_messages():
+    c = sseclient.SSEClient('http://blah.com',chunk_size=51)
+    assert next(c).data == 'ööööööööööööööööööööööööööööööööööööööööööööööööööööööööö'
+    assert next(c).data == 'äääääääääääääääääääääääääääääääääääääääääääääääääääääääää'
+    assert next(c).data == 'üüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüüü'
+
 def test_event_stream():
     """Check whether event.data can be loaded."""
     limit = 50