Repository: encode/httpx

Issue:
aiter_lines() doesn't return full lines that span multiple chunks
<https://gist.github.com/scr-oath/aa76d200222a0409d09a0d6feb1a13e2> shows an example setup using cherry.py as server that just outputs two lines - the json is big enough to be sent in two chunks; httpx aiter_lines() gets confused and sends data from the middle of the json line - seems to skip the starting part - which was most likely sent in a chunk without a newline

### test-httpx.py
```python
import asyncio
import json

import httpx


class TestHttpx:
    def __init__(self):
        pass

    async def __call__(self):
        http_client = httpx.AsyncClient()
        async with http_client.stream(method="GET", url='http://localhost:8080/lines') as response:
            is_message = True
            async for line in response.aiter_lines():
                is_message = not is_message
                if is_message:
                    message = json.loads(line)
                    print(message)


def main():
    test_httpx = TestHttpx()
    asyncio.run(test_httpx())


if __name__ == '__main__':
    main()
```


Discussion:
It seems like the LineDecoder needs to remember partial work when called again so that it can prepend the new text with the previous line.
Here's a really small test case:
```python
    decoder = LineDecoder()
    assert decoder.decode("") == []
    assert decoder.decode("12345\r\n") == ["12345\n"]
    assert decoder.decode("foo ") == []
    assert decoder.decode("bar ") == []
    assert decoder.decode("baz\r\n") == ["foo bar baz\n"]
```

```
FAILED                          [100%]
tests/test_decoders.py:240 (test_line_decoder_crnl)
['bar baz
'] != ['foo bar baz
']
```

Resolving patch:
diff --git a/httpx/_decoders.py b/httpx/_decoders.py
--- a/httpx/_decoders.py
+++ b/httpx/_decoders.py
@@ -261,7 +261,7 @@ def decode(self, text: str) -> typing.List[str]:
                     text = text[idx + 1 :]
                     break
                 elif next_char is None:
-                    self.buffer = text
+                    self.buffer += text
                     text = ""
                     break
 


Test patch:
diff --git a/tests/test_decoders.py b/tests/test_decoders.py
--- a/tests/test_decoders.py
+++ b/tests/test_decoders.py
@@ -225,6 +225,15 @@ def test_line_decoder_nl():
     assert decoder.decode("a\n\nb\nc\n") == ["a\n", "\n", "b\n", "c\n"]
     assert decoder.flush() == []
 
+    # Issue #1033
+    decoder = LineDecoder()
+    assert decoder.decode("") == []
+    assert decoder.decode("12345\n") == ["12345\n"]
+    assert decoder.decode("foo ") == []
+    assert decoder.decode("bar ") == []
+    assert decoder.decode("baz\n") == ["foo bar baz\n"]
+    assert decoder.flush() == []
+
 
 def test_line_decoder_cr():
     decoder = LineDecoder()
@@ -237,6 +246,16 @@ def test_line_decoder_cr():
     assert decoder.decode("a\r\rb\rc\r") == ["a\n", "\n", "b\n"]
     assert decoder.flush() == ["c\n"]
 
+    # Issue #1033
+    # TODO: This seems like another bug; fix expectations and results.
+    decoder = LineDecoder()
+    assert decoder.decode("") == []
+    assert decoder.decode("12345\r") == []
+    assert decoder.decode("foo ") == []
+    assert decoder.decode("bar ") == []
+    assert decoder.decode("baz\r") == []
+    assert decoder.flush() == ["12345\rfoo bar baz\n"]
+
 
 def test_line_decoder_crnl():
     decoder = LineDecoder()
@@ -255,6 +274,15 @@ def test_line_decoder_crnl():
     assert decoder.decode("\n\r\nb\r\nc") == ["a\n", "\n", "b\n"]
     assert decoder.flush() == ["c"]
 
+    # Issue #1033
+    decoder = LineDecoder()
+    assert decoder.decode("") == []
+    assert decoder.decode("12345\r\n") == ["12345\n"]
+    assert decoder.decode("foo ") == []
+    assert decoder.decode("bar ") == []
+    assert decoder.decode("baz\r\n") == ["foo bar baz\n"]
+    assert decoder.flush() == []
+
 
 def test_invalid_content_encoding_header():
     headers = [(b"Content-Encoding", b"invalid-header")]