From 1a007a54a6d9750fa9162b318e146dddde53d37c Mon Sep 17 00:00:00 2001 From: Kayvan Zahiri <123409429+Kayvan-Zahiri@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:28:38 -0700 Subject: [PATCH] fix(python): preserve final JSON Lines records without a trailing newline --- .changesets/1792556768-64f74b9f.yaml | 10 +++ .../{{getSourcePath}}/utils/jsonl.py.stmpl | 4 +- .../test_jsonl_streaming_additional.py | 61 +++++++++++++++++++ 3 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 .changesets/1792556768-64f74b9f.yaml diff --git a/.changesets/1792556768-64f74b9f.yaml b/.changesets/1792556768-64f74b9f.yaml new file mode 100644 index 000000000..6d3737c05 --- /dev/null +++ b/.changesets/1792556768-64f74b9f.yaml @@ -0,0 +1,10 @@ +id: 1792556768-64f74b9f +features: + - jsonlResponses +targets: + - pythonv2 +type: fix +bump: patch +description: preserve final JSON Lines records without a trailing newline +author: Kayvan-Zahiri +date: "2026-10-20" diff --git a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/jsonl.py.stmpl b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/jsonl.py.stmpl index 438aa25a5..37bca79cf 100644 --- a/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/jsonl.py.stmpl +++ b/templates/templates/pythonv2/auxiliary/{{getSourcePath}}/utils/jsonl.py.stmpl @@ -110,7 +110,7 @@ async def stream_jsonl_events_async( buffer = buffer[position:] position = 0 - if position != 0: + if buffer.strip(): event = decoder(buffer.decode()) yield event @@ -145,7 +145,7 @@ def stream_jsonl_events( buffer = buffer[position:] position = 0 - if position != 0: + if buffer.strip(): event = decoder(buffer.decode()) yield event diff --git a/templates/templates/pythonv2/tests/primary/test_jsonl_streaming_additional.py b/templates/templates/pythonv2/tests/primary/test_jsonl_streaming_additional.py index 09c3f28b9..4ae6f4888 100644 --- a/templates/templates/pythonv2/tests/primary/test_jsonl_streaming_additional.py +++ b/templates/templates/pythonv2/tests/primary/test_jsonl_streaming_additional.py @@ -1,3 +1,5 @@ +import json +import httpx import time import pytest from openapi import SDK @@ -5,6 +7,7 @@ from openapi.models.shared import * from openapi.utils import * from openapi.utils import jsonl +from openapi.utils.jsonl import JsonLStream, JsonLStreamAsync from .common_helpers import * from .test_helpers import * @@ -164,3 +167,61 @@ async def create_generator(): nb_chunks += 1 assert nb_chunks == 2, "The generator should yield exactly 2 chunks" + + +CASES = [ + (b"", []), + (b'{"name":"one"}\n', [{"name": "one"}]), + (b'{"name":"one"}', [{"name": "one"}]), + (b'{"name":"one"}\n{"name":"two"}', [{"name": "one"}, {"name": "two"}]), + (b'{"name":"one"}\r\n{"name":"two"}', [{"name": "one"}, {"name": "two"}]), + (b'{"name":"one"}\n \t\r\n', [{"name": "one"}]), + ('{"name":"café"}'.encode(), [{"name": "café"}]), + (b'{"name":"one"}\r\n{"name":"two"}\r\n', [{"name": "one"}, {"name": "two"}]), +] + + +class ChunkStream(httpx.SyncByteStream, httpx.AsyncByteStream): + def __init__(self, payload, chunk_size): + self.chunks = [payload] if chunk_size is None else [ + payload[i:i + chunk_size] for i in range(0, len(payload), chunk_size) + ] + + def __iter__(self): + yield from self.chunks + + async def __aiter__(self): + for chunk in self.chunks: + yield chunk + + +@pytest.mark.parametrize("payload, expected", CASES) +@pytest.mark.parametrize("chunk_size", [None, 1, 5]) +def test_jsonl_final_record(payload, expected, chunk_size): + response = httpx.Response(200, stream=ChunkStream(payload, chunk_size)) + with JsonLStream(response, json.loads) as events: + assert list(events) == expected + assert response.is_closed + + +@pytest.mark.asyncio +@pytest.mark.parametrize("payload, expected", CASES) +@pytest.mark.parametrize("chunk_size", [None, 1, 5]) +async def test_jsonl_final_record_async(payload, expected, chunk_size): + response = httpx.Response(200, stream=ChunkStream(payload, chunk_size)) + async with JsonLStreamAsync(response, json.loads) as events: + assert [event async for event in events] == expected + assert response.is_closed + + +def test_jsonl_invalid_final_record(): + response = httpx.Response(200, stream=ChunkStream(b'{"name":', 1)) + with pytest.raises(json.JSONDecodeError): + list(JsonLStream(response, json.loads)) + + +@pytest.mark.asyncio +async def test_jsonl_invalid_final_record_async(): + response = httpx.Response(200, stream=ChunkStream(b'{"name":', 1)) + with pytest.raises(json.JSONDecodeError): + [event async for event in JsonLStreamAsync(response, json.loads)]