Repository: mpetazzoni/sseclient

Issue:
Invalid json when newline is at the beginning of a chunk
Hi, If the response chunk happens to have a newline character at the beginning then it is incorrectly parsed as an SSE delimiter resulting in invalid json in event.data
ex: 

```
chunk = b'\ndata:   "logicalTimestampMs" : 1497016850000,\ndata:   "maxDelayMs" : 12000\ndata: }\n\nevent: data\nid: data-1497016860000\ndata: {\n'
chunk.splitlines(True)
['\n', 'data:   "logicalTimestampMs" : 1497016850000,\n', 'data:   "maxDelayMs" : 12000\n', 'data: }\n', '\n', 'event: data\n', 'id: data-1497016860000\n', 'data: {\n']
```

Comments:

Patch:
diff --git a/sseclient/__init__.py b/sseclient/__init__.py
index 3bd32e0..4fb502b 100644
--- a/sseclient/__init__.py
+++ b/sseclient/__init__.py
@@ -8,7 +8,6 @@ Provides a generator of SSE received through an existing HTTP response.
 
 import logging
 
-
 __author__ = 'Maxime Petazzoni <maxime.petazzoni@bulix.org>'
 __email__ = 'maxime.petazzoni@bulix.org'
 __copyright__ = 'Copyright (C) 2016-2017 SignalFx, Inc. All rights reserved.'
@@ -32,8 +31,9 @@ class SSEClient(object):
         method. That would usually be something that implements
         io.BinaryIOBase, like an httplib or urllib3 HTTPResponse object.
         """
-        logging.debug('Initialized SSE client from event source %s',
-                      event_source)
+        self._logger = logging.getLogger(self.__class__.__module__)
+        self._logger.debug('Initialized SSE client from event source %s',
+                           event_source)
         self._event_source = event_source
         self._char_enc = char_enc
 
@@ -47,10 +47,10 @@ class SSEClient(object):
         data = b''
         for chunk in self._event_source:
             for line in chunk.splitlines(True):
-                if not line.strip():
+                data += line
+                if data.endswith((b'\r\r', b'\n\n', b'\r\n\r\n')):
                     yield data
                     data = b''
-                data += line
         if data:
             yield data
 
@@ -72,13 +72,22 @@ class SSEClient(object):
 
                 # Ignore unknown fields.
                 if field not in event.__dict__:
-                    logging.debug('Saw invalid field %s while parsing '
-                                  'Server Side Event', field)
+                    self._logger.debug('Saw invalid field %s while parsing '
+                                       'Server Side Event', field)
                     continue
 
-                # Spaces may occur before the value; strip them. If no value is
-                # present after the separator, assume an empty value.
-                value = data[1].lstrip() if len(data) > 1 else ''
+                if len(data) > 1:
+                    # From the spec:
+                    # "If value starts with a single U+0020 SPACE character,
+                    # remove it from value."
+                    if data[1].startswith(' '):
+                        value = data[1][1:]
+                    else:
+                        value = data[1]
+                else:
+                    # If no value is present after the separator,
+                    # assume an empty value.
+                    value = ''
 
                 # The data field may come over multiple lines and their values
                 # are concatenated with each other.
@@ -95,8 +104,11 @@ class SSEClient(object):
             if event.data.endswith('\n'):
                 event.data = event.data[0:-1]
 
+            # Empty event names default to 'message'
+            event.event = event.event or 'message'
+
             # Dispatch the event
-            logging.debug('Dispatching %s...', event)
+            self._logger.debug('Dispatching %s...', event)
             yield event
 
     def close(self):
diff --git a/sseclient/version.py b/sseclient/version.py
index 273eb95..4c6e6f8 100644
--- a/sseclient/version.py
+++ b/sseclient/version.py
@@ -1,2 +1,2 @@
 name = 'sseclient-py'
-version = '1.5'
+version = '1.7'


Test patch:
diff --git a/test.py b/test.py
new file mode 100644
index 0000000..ab0dc07
--- /dev/null
+++ b/test.py
@@ -0,0 +1,248 @@
+# coding: utf-8
+"""
+Tests mosly copied from:
+
+https://github.com/EventSource/eventsource/blob/master/test/eventsource_test.js
+"""
+
+import os
+import sys
+import struct
+import unittest
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+import sseclient
+
+def parse(content):
+    return [{ 'id': ev.id,
+              'event': ev.event,
+              'data': ev.data }
+            for ev in sseclient.SSEClient(content).events()]
+
+class Parser(unittest.TestCase):
+    def test_multibyte_characters(self):
+        self.assertEqual(parse([u'id: 1\ndata: €豆腐\n\n'.encode('utf-8')]),
+        [{
+            'id': '1',
+            'event': 'message',
+            'data': u'€豆腐'
+        }])
+
+    def test_parses_empty_lines_with_multibyte_characters(self):
+        self.assertEqual(parse([u'\n\n\n\nid: 1\ndata: 我現在都看實況不玩遊戲\n\n'.encode('utf-8')]),
+        [{
+            'id': '1',
+            'event': 'message',
+            'data': u'我現在都看實況不玩遊戲'
+        }])
+
+    def test_one_one_line_message_in_one_chunk(self):
+        self.assertEqual(parse([b'data: Hello\n\n']),
+        [{
+            'id': None,
+            'event': 'message',
+            'data': 'Hello'
+        }])
+
+    def test_one_one_line_message_in_two_chunks(self):
+        self.assertEqual(parse([b'data: Hel', b'lo\n\n']),
+        [{
+            'id': None,
+            'event': 'message',
+            'data': 'Hello'
+        }])
+
+    def test_two_one_line_messages_in_one_chunk(self):
+        self.assertEqual(parse([b'data: Hello\n\n', b'data: World\n\n']),
+        [{
+            'id': None,
+            'event': 'message',
+            'data': 'Hello'
+        }, {
+            'id': None,
+            'event': 'message',
+            'data': 'World'
+        }])
+
+    def test_one_two_line_message_in_one_chunk(self):
+        self.assertEqual(parse([b'data: Hello\ndata:World\n\n']),
+        [{
+            'id': None,
+            'event': 'message',
+            'data': 'Hello\nWorld'
+        }])
+
+    # Issue #8
+    def test_really_chopped_up_unicode_data(self):
+        self.assertEqual(parse([struct.pack('B', b) for b in bytearray(u'data: Aslak\n\ndata: Hellesøy\n\n'.encode('utf-8'))]),
+        [{
+            'id': None,
+            'event': 'message',
+            'data': 'Aslak'
+        }, {
+            'id': None,
+            'event': 'message',
+            'data': u'Hellesøy'
+        }])
+
+    def test_accepts_CRLF_as_separator(self):
+        self.assertEqual(parse([struct.pack('B', b) for b in bytearray(u'data: Aslak\r\n\r\ndata: Hellesøy\r\n\r\n'.encode('utf-8'))]),
+        [{
+            'id': None,
+            'event': 'message',
+            'data': 'Aslak'
+        }, {
+            'id': None,
+            'event': 'message',
+            'data': u'Hellesøy'
+        }])
+
+    def test_accepts_CR_as_separator(self):
+        self.assertEqual(parse([struct.pack('B', b) for b in bytearray(u'data: Aslak\r\rdata: Hellesøy\r\r'.encode('utf-8'))]),
+        [{
+            'id': None,
+            'event': 'message',
+            'data': 'Aslak'
+        }, {
+            'id': None,
+            'event': 'message',
+            'data': u'Hellesøy'
+        }])
+
+    def test_delivers_message_with_explicit_event(self):
+        self.assertEqual(parse([b'event: greeting\ndata: Hello\n\n']),
+        [{
+            'id': None,
+            'event': 'greeting',
+            'data': 'Hello'
+        }])
+
+    def test_delivers_two_messages_with_same_explicit_event(self):
+        self.assertEqual(parse([b'event: greeting\ndata: Hello\n\n', b'event: greeting\ndata: World\n\n']),
+        [{
+            'id': None,
+            'event': 'greeting',
+            'data': 'Hello'
+        },
+        {
+            'id': None,
+            'event': 'greeting',
+            'data': 'World'
+        }])
+
+    def test_delivers_two_messages_with_different_explicit_events(self):
+        self.assertEqual(parse([b'event: greeting\ndata: Hello\n\n', b'event: salutation\ndata: World\n\n']),
+        [{
+            'id': None,
+            'event': 'greeting',
+            'data': 'Hello'
+        },
+        {
+            'id': None,
+            'event': 'salutation',
+            'data': 'World'
+        }])
+
+    def test_ignores_comments(self):
+        self.assertEqual(parse([b'data: Hello\n\n:nothing to see here\n\ndata: World\n\n']),
+        [{
+            'id': None,
+            'event': 'message',
+            'data': 'Hello'
+        },
+        {
+            'id': None,
+            'event': 'message',
+            'data': 'World'
+        }])
+       
+    def test_ignores_empty_comments(self):
+        self.assertEqual(parse([b'data: Hello\n\n:\n\ndata: World\n\n']),
+        [{
+            'id': None,
+            'event': 'message',
+            'data': 'Hello'
+        },
+        {
+            'id': None,
+            'event': 'message',
+            'data': 'World'
+        }])
+ 
+    def test_does_not_ignore_multiline_strings(self):
+        self.assertEqual(parse([b'data: line one\ndata:\ndata: line two\n\n']),
+        [{
+            'id': None,
+            'event': 'message',
+            'data': 'line one\n\nline two'
+        }])
+
+    def test_does_not_ignore_multiline_strings_even_in_data_beginning(self):
+        self.assertEqual(parse([b'data:\ndata:line one\ndata: line two\n\n']),
+        [{
+            'id': None,
+            'event': 'message',
+            'data': '\nline one\nline two'
+        }])
+
+    def test_should_regard_empty_event_as_message(self):
+        self.assertEqual(parse([b'event:\ndata: Hello\n\n']),
+        [{
+            'id': None,
+            'event': 'message',
+            'data': 'Hello'
+        }])
+
+    def test_should_ignore_message_with_no_data(self):
+        self.assertEqual(parse([b'event: greeting\n\n']), [])
+
+    def test_preserves_whitespace_at_end_of_lines(self):
+        self.assertEqual(parse([b'event: greeting \ndata: Hello  \n\n']),
+        [{
+            'id': None,
+            'event': 'greeting ',
+            'data': 'Hello  '
+        }])
+
+    def test_parses_relatively_huge_messages_efficiently(self):
+        self.assertEqual(parse([b'data: ' + b'a'*10000 + b'\n\n']),
+        [{
+            'id': None,
+            'event': 'message',
+            'data': 'a'*10000
+        }])
+
+    def test_ID(self):
+        self.assertEqual(parse([b'id: 90\ndata: Hello\n\n']),
+        [{
+            'id': '90',
+            'event': 'message',
+            'data': 'Hello'
+        }])
+
+    # Issue #9
+    def test_does_not_split_on_universal_newlines(self):
+        self.assertEqual(parse([u'data: Hello\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029\n\n'.encode('utf-8')]),
+        [{
+            'id': None,
+            'event': 'message',
+            'data': u'Hello\x0b\x0c\x1c\x1d\x1e\x85\u2028\u2029'
+        }])
+
+    # Issue #11 and PR #12
+    def test_empty_line_at_start_of_chunk(self):
+        self.assertEqual(parse([
+            b'event: test event\r\ndata: {\r\ndata:     "terribly_split": "json_objects in SSE",',
+            b'\r\ndata:     "which_should_probably": "be on a single line",\r\ndata:     "but_oh_well": 1\r\ndata: }\r\n\r\n']),
+        [{
+            'id': None,
+            'event': 'test event',
+            # From the spec https://www.w3.org/TR/2009/WD-eventsource-20091029/
+            # "If the field name is "data"
+            #  Append the field value to the data buffer, then append a single
+            #  U+000A LINE FEED (LF) character to the data buffer."
+            # So the lines should be separated by \n
+            'data': '{\n    "terribly_split": "json_objects in SSE",\n    "which_should_probably": "be on a single line",\n    "but_oh_well": 1\n}'
+        }])
+
+if __name__ == '__main__':
+    unittest.main()