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()
```
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
']
```
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
@@ -233,12 +233,18 @@ def __init__(self) -> None:
     def decode(self, text: str) -> typing.List[str]:
         lines = []
 
-        if text.startswith("\n") and self.buffer and self.buffer[-1] == "\r":
-            # Handle the case where we have an "\r\n" split across
-            # our previous input, and our new chunk.
-            lines.append(self.buffer[:-1] + "\n")
-            self.buffer = ""
-            text = text[1:]
+        if text and self.buffer and self.buffer[-1] == "\r":
+            if text.startswith("\n"):
+                # Handle the case where we have an "\r\n" split across
+                # our previous input, and our new chunk.
+                lines.append(self.buffer[:-1] + "\n")
+                self.buffer = ""
+                text = text[1:]
+            else:
+                # Handle the case where we have "\r" at the end of our
+                # previous input.
+                lines.append(self.buffer[:-1] + "\n")
+                self.buffer = ""
 
         while text:
             num_chars = len(text)


Test patch:
diff --git a/tests/test_decoders.py b/tests/test_decoders.py
--- a/tests/test_decoders.py
+++ b/tests/test_decoders.py
@@ -247,14 +247,13 @@ def test_line_decoder_cr():
     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("foo ") == ["12345\n"]
     assert decoder.decode("bar ") == []
     assert decoder.decode("baz\r") == []
-    assert decoder.flush() == ["12345\rfoo bar baz\n"]
+    assert decoder.flush() == ["foo bar baz\n"]
 
 
 def test_line_decoder_crnl():