pax_global_header00006660000000000000000000000064150722772030014517gustar00rootroot0000000000000052 comment=e8fcd9e159066185963ffb9fa29efb8ba2ca84bf python-httpx-sse-0.4.3/000077500000000000000000000000001507227720300150015ustar00rootroot00000000000000python-httpx-sse-0.4.3/.gitignore000066400000000000000000000002161507227720300167700ustar00rootroot00000000000000# Tooling. .coverage venv*/ # Caches. __pycache__/ *.pyc .mypy_cache/ .pytest_cache/ # Packaging. build/ dist/ *.egg-info/ # Private. .env python-httpx-sse-0.4.3/CHANGELOG.md000066400000000000000000000033111507227720300166100ustar00rootroot00000000000000# Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## 0.4.3 - 2025-10-10 ### Fixed * Fix performance issue introduced by the improved line parsing from release 0.4.2. (Pull #40) ## 0.4.2 - 2025-10-07 ### Fixed * Fix incorrect newline parsing that was not compliant with SSE spec. (Pull #37) ## 0.4.1 - 2025-06-24 ### Fixed * Always close the response async generator in `aiter_sse()`. (Pull #30) ## 0.4.0 - 2023-12-22 ### Removed * Dropped Python 3.7 support, as it has reached EOL. (Pull #21) ### Added * Add official support for Python 3.12. (Pull #21) ### Fixed * Allow `Content-Type` that contain but are not strictly `text/event-stream`. (Pull #22 by @dbuades) * Improve error message when `Content-Type` is missing. (Pull #20 by @jamesbraza) ## 0.3.1 - 2023-06-01 ### Added * Add `__repr__()` for `ServerSentEvent` model, which may help with debugging and other tasks. (Pull #16) ## 0.3.0 - 2023-04-27 ### Changed * Raising an `SSEError` if the response content type is not `text/event-stream` is now performed as part of `iter_sse()` / `aiter_sse()`, instead of `connect_sse()` / `aconnect_sse()`. This allows inspecting the response before iterating on server-sent events, such as checking for error responses. (Pull #12) ## 0.2.0 - 2023-03-27 ### Changed * `connect_sse()` and `aconnect_sse()` now require a `method` argument: `connect_sse(client, "GET", "https://example.org")`. This provides support for SSE requests with HTTP verbs other than `GET`. (Pull #7) ## 0.1.0 - 2023-02-05 _Initial release_ ### Added * Add `connect_sse`, `aconnect_sse()`, `ServerSentEvent` and `SSEError`. python-httpx-sse-0.4.3/LICENSE000066400000000000000000000020601507227720300160040ustar00rootroot00000000000000MIT License Copyright (c) 2022 Florimond Manca Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. python-httpx-sse-0.4.3/MANIFEST.in000066400000000000000000000001011507227720300165270ustar00rootroot00000000000000graft src include README.md include CHANGELOG.md include LICENSE python-httpx-sse-0.4.3/Makefile000066400000000000000000000007461507227720300164500ustar00rootroot00000000000000venv = venv bin = ${venv}/bin/ pysources = src tests/ install: install-python install-python: python3 -m venv ${venv} ${bin}pip install -U pip wheel ${bin}pip install -U build ${bin}pip install -r requirements.txt check: ${bin}black --check --diff ${pysources} ${bin}ruff check ${pysources} ${bin}mypy ${pysources} format: ${bin}ruff check --fix ${pysources} ${bin}black ${pysources} build: ${bin}python -m build publish: ${bin}twine upload dist/* test: ${bin}pytest python-httpx-sse-0.4.3/README.md000066400000000000000000000157221507227720300162670ustar00rootroot00000000000000# httpx-sse [![Build Status](https://dev.azure.com/florimondmanca/public/_apis/build/status/florimondmanca.httpx-sse?branchName=master)](https://dev.azure.com/florimondmanca/public/_build?definitionId=19) [![Coverage](https://codecov.io/gh/florimondmanca/httpx-sse/branch/master/graph/badge.svg)](https://codecov.io/gh/florimondmanca/httpx-sse) [![Package version](https://badge.fury.io/py/httpx-sse.svg)](https://pypi.org/project/httpx-sse) Consume [Server-Sent Event (SSE)](https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events) messages with [HTTPX](https://www.python-httpx.org). **Table of contents** - [Installation](#installation) - [Quickstart](#quickstart) - [How-To](#how-to) - [API Reference](#api-reference) ## Installation **NOTE**: This is beta software. Please be sure to pin your dependencies. ```bash pip install httpx-sse=="0.4.*" ``` ## Quickstart `httpx-sse` provides the [`connect_sse`](#connect_sse) and [`aconnect_sse`](#aconnect_sse) helpers for connecting to an SSE endpoint. The resulting [`EventSource`](#eventsource) object exposes the [`.iter_sse()`](#iter_sse) and [`.aiter_sse()`](#aiter_sse) methods to iterate over the server-sent events. Example usage: ```python import httpx from httpx_sse import connect_sse with httpx.Client() as client: with connect_sse(client, "GET", "http://localhost:8000/sse") as event_source: for sse in event_source.iter_sse(): print(sse.event, sse.data, sse.id, sse.retry) ``` You can try this against this example Starlette server ([credit](https://sysid.github.io/sse/)): ```python # Requirements: pip install uvicorn starlette sse-starlette import asyncio import uvicorn from starlette.applications import Starlette from starlette.routing import Route from sse_starlette.sse import EventSourceResponse async def numbers(minimum, maximum): for i in range(minimum, maximum + 1): await asyncio.sleep(0.9) yield {"data": i} async def sse(request): generator = numbers(1, 5) return EventSourceResponse(generator) routes = [ Route("/sse", endpoint=sse) ] app = Starlette(routes=routes) if __name__ == "__main__": uvicorn.run(app) ``` ## How-To ### Calling into Python web apps You can [call into Python web apps](https://www.python-httpx.org/async/#calling-into-python-web-apps) with HTTPX and `httpx-sse` to test SSE endpoints directly. Here's an example of calling into a Starlette ASGI app... ```python import asyncio import httpx from httpx_sse import aconnect_sse from sse_starlette.sse import EventSourceResponse from starlette.applications import Starlette from starlette.routing import Route async def auth_events(request): async def events(): yield { "event": "login", "data": '{"user_id": "4135"}', } return EventSourceResponse(events()) app = Starlette(routes=[Route("/sse/auth/", endpoint=auth_events)]) async def main(): async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: async with aconnect_sse( client, "GET", "http://localhost:8000/sse/auth/" ) as event_source: events = [sse async for sse in event_source.aiter_sse()] (sse,) = events assert sse.event == "login" assert sse.json() == {"user_id": "4135"} asyncio.run(main()) ``` ### Handling reconnections _(Advanced)_ `SSETransport` and `AsyncSSETransport` don't have reconnection built-in. This is because how to perform retries is generally dependent on your use case. As a result, if the connection breaks while attempting to read from the server, you will get an `httpx.ReadError` from `iter_sse()` (or `aiter_sse()`). However, `httpx-sse` does allow implementing reconnection by using the `Last-Event-ID` and reconnection time (in milliseconds), exposed as `sse.id` and `sse.retry` respectively. Here's how you might achieve this using [`stamina`](https://github.com/hynek/stamina)... ```python import time from typing import Iterator import httpx from httpx_sse import connect_sse, ServerSentEvent from stamina import retry def iter_sse_retrying(client, method, url): last_event_id = "" reconnection_delay = 0.0 # `stamina` will apply jitter and exponential backoff on top of # the `retry` reconnection delay sent by the server. @retry(on=httpx.ReadError) def _iter_sse(): nonlocal last_event_id, reconnection_delay time.sleep(reconnection_delay) headers = {"Accept": "text/event-stream"} if last_event_id: headers["Last-Event-ID"] = last_event_id with connect_sse(client, method, url, headers=headers) as event_source: for sse in event_source.iter_sse(): last_event_id = sse.id if sse.retry is not None: reconnection_delay = sse.retry / 1000 yield sse return _iter_sse() ``` Usage: ```python with httpx.Client() as client: for sse in iter_sse_retrying(client, "GET", "http://localhost:8000/sse"): print(sse.event, sse.data) ``` ## API Reference ### `connect_sse` ```python def connect_sse( client: httpx.Client, method: str, url: Union[str, httpx.URL], **kwargs, ) -> ContextManager[EventSource] ``` Connect to an SSE endpoint and return an [`EventSource`](#eventsource) context manager. This sets `Cache-Control: no-store` on the request, as per the SSE spec, as well as `Accept: text/event-stream`. If the response `Content-Type` is not `text/event-stream`, this will raise an [`SSEError`](#sseerror). ### `aconnect_sse` ```python async def aconnect_sse( client: httpx.AsyncClient, method: str, url: Union[str, httpx.URL], **kwargs, ) -> AsyncContextManager[EventSource] ``` An async equivalent to [`connect_sse`](#connect_sse). ### `EventSource` ```python def __init__(response: httpx.Response) ``` Helper for working with an SSE response. #### `response` The underlying [`httpx.Response`](https://www.python-httpx.org/api/#response). You may use this to perform more operations and checks on the response, such as checking for HTTP status errors: ```python with connect_sse(...) as event_source: event_source.response.raise_for_status() for sse in event_source.iter_sse(): ... ``` #### `iter_sse` ```python def iter_sse() -> Iterator[ServerSentEvent] ``` Decode the response content and yield corresponding [`ServerSentEvent`](#serversentevent). Example usage: ```python for sse in event_source.iter_sse(): ... ``` #### `aiter_sse` ```python async def iter_sse() -> AsyncIterator[ServerSentEvent] ``` An async equivalent to `iter_sse`. ### `ServerSentEvent` Represents a server-sent event. * `event: str` - Defaults to `"message"`. * `data: str` - Defaults to `""`. * `id: str` - Defaults to `""`. * `retry: str | None` - Defaults to `None`. Methods: * `json() -> Any` - Returns `sse.data` decoded as JSON. ### `SSEError` An error that occurred while making a request to an SSE endpoint. Parents: * `httpx.TransportError` ## License MIT python-httpx-sse-0.4.3/ci/000077500000000000000000000000001507227720300153745ustar00rootroot00000000000000python-httpx-sse-0.4.3/ci/azure-pipelines.yml000066400000000000000000000015751507227720300212430ustar00rootroot00000000000000resources: repositories: - repository: templates type: github endpoint: github name: florimondmanca/azure-pipelines-templates ref: refs/tags/6.3 trigger: - master - refs/tags/* pr: - master variables: - name: CI value: "true" - name: PIP_CACHE_DIR value: $(Pipeline.Workspace)/.cache/pip - group: pypi-credentials stages: - stage: test jobs: - template: job--python-check.yml@templates parameters: pythonVersion: "3.13" - template: job--python-test.yml@templates parameters: jobs: py39: py313: coverage: true - stage: publish condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/') jobs: - template: job--python-publish.yml@templates parameters: pythonVersion: "3.13" token: $(pypiToken) python-httpx-sse-0.4.3/pyproject.toml000066400000000000000000000021101507227720300177070ustar00rootroot00000000000000[build-system] requires = ["setuptools", "setuptools-scm", "wheel"] build-backend = "setuptools.build_meta" [project] name = "httpx-sse" description = "Consume Server-Sent Event (SSE) messages with HTTPX." requires-python = ">=3.9" license = { text = "MIT" } authors = [ { name = "Florimond Manca", email = "florimond.manca@protonmail.com" }, ] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] dependencies = [] dynamic = ["version", "readme"] [project.urls] "Homepage" = "https://github.com/florimondmanca/httpx-sse" [tool.setuptools.dynamic] version = { attr = "httpx_sse.__version__" } readme = { file = ["README.md", "CHANGELOG.md"], content-type = "text/markdown" } [tool.ruff] select = ["E", "F", "I"] line-length = 88 src = ["src"] python-httpx-sse-0.4.3/requirements.txt000066400000000000000000000004711507227720300202670ustar00rootroot00000000000000-e . # Tooling and tests. black==23.12.0 httpx==0.26.0 mypy==1.8.0 pytest==7.4.3 pytest-asyncio==0.21.1 pytest-cov ruff==0.1.9 sse-starlette==1.8.2 starlette==0.27.0 # sse-starlette installs fastapi which requires starlette==0.27.* at latest: https://github.com/sysid/sse-starlette/issues/85 # Releasing. twine python-httpx-sse-0.4.3/setup.cfg000066400000000000000000000003061507227720300166210ustar00rootroot00000000000000[mypy] disallow_untyped_defs = True ignore_missing_imports = True [tool:pytest] asyncio_mode = strict addopts = -rxXs --cov=src --cov=tests --cov-report=term-missing --cov-fail-under=100 python-httpx-sse-0.4.3/setup.py000066400000000000000000000000741507227720300165140ustar00rootroot00000000000000from setuptools import setup setup() # Editable installs. python-httpx-sse-0.4.3/src/000077500000000000000000000000001507227720300155705ustar00rootroot00000000000000python-httpx-sse-0.4.3/src/httpx_sse/000077500000000000000000000000001507227720300176115ustar00rootroot00000000000000python-httpx-sse-0.4.3/src/httpx_sse/__init__.py000066400000000000000000000004321507227720300217210ustar00rootroot00000000000000from ._api import EventSource, aconnect_sse, connect_sse from ._exceptions import SSEError from ._models import ServerSentEvent __version__ = "0.4.3" __all__ = [ "__version__", "EventSource", "connect_sse", "aconnect_sse", "ServerSentEvent", "SSEError", ] python-httpx-sse-0.4.3/src/httpx_sse/_api.py000066400000000000000000000055471507227720300211060ustar00rootroot00000000000000from collections.abc import AsyncGenerator from contextlib import asynccontextmanager, contextmanager from typing import Any, AsyncIterator, Iterator, cast import httpx from ._decoders import SSEDecoder, SSELineDecoder from ._exceptions import SSEError from ._models import ServerSentEvent class EventSource: def __init__(self, response: httpx.Response) -> None: self._response = response def _check_content_type(self) -> None: content_type = self._response.headers.get("content-type", "").partition(";")[0] if "text/event-stream" not in content_type: raise SSEError( "Expected response header Content-Type to contain 'text/event-stream', " f"got {content_type!r}" ) @property def response(self) -> httpx.Response: return self._response def iter_sse(self) -> Iterator[ServerSentEvent]: self._check_content_type() decoder = SSEDecoder() for line in _iter_sse_lines(self._response): line = line.rstrip("\n") sse = decoder.decode(line) if sse is not None: yield sse async def aiter_sse(self) -> AsyncGenerator[ServerSentEvent, None]: self._check_content_type() decoder = SSEDecoder() lines = cast(AsyncGenerator[str, None], _aiter_sse_lines(self._response)) try: async for line in lines: line = line.rstrip("\n") sse = decoder.decode(line) if sse is not None: yield sse finally: await lines.aclose() @contextmanager def connect_sse( client: httpx.Client, method: str, url: str, **kwargs: Any ) -> Iterator[EventSource]: headers = kwargs.pop("headers", {}) headers["Accept"] = "text/event-stream" headers["Cache-Control"] = "no-store" with client.stream(method, url, headers=headers, **kwargs) as response: yield EventSource(response) @asynccontextmanager async def aconnect_sse( client: httpx.AsyncClient, method: str, url: str, **kwargs: Any, ) -> AsyncIterator[EventSource]: headers = kwargs.pop("headers", {}) headers["Accept"] = "text/event-stream" headers["Cache-Control"] = "no-store" async with client.stream(method, url, headers=headers, **kwargs) as response: yield EventSource(response) async def _aiter_sse_lines(response: httpx.Response) -> AsyncIterator[str]: decoder = SSELineDecoder() async for text in response.aiter_text(): for line in decoder.decode(text): yield line for line in decoder.flush(): yield line def _iter_sse_lines(response: httpx.Response) -> Iterator[str]: decoder = SSELineDecoder() for text in response.iter_text(): for line in decoder.decode(text): yield line for line in decoder.flush(): yield line python-httpx-sse-0.4.3/src/httpx_sse/_decoders.py000066400000000000000000000076451507227720300221260ustar00rootroot00000000000000from typing import List, Optional from ._models import ServerSentEvent def _splitlines_sse(text: str) -> List[str]: """Split text on \r\n, \r, or \n only.""" if not text: return [] if "\r" not in text: lines = text.split("\n") else: normalized = text.replace("\r\n", "\n").replace("\r", "\n") lines = normalized.split("\n") if text[-1] in "\r\n": lines.pop() return lines class SSELineDecoder: """ Handles incrementally reading lines from text. Mostly a copy of httpx._decoders.LineDecoder, but as per SSE spec, only \r\n, \r, and \n are treated as newlines, which differs from the behavior of splitlines() used by httpx._decoders.LineDecoder. """ def __init__(self) -> None: self.buffer: list[str] = [] self.trailing_cr: bool = False def decode(self, text: str) -> list[str]: # We always push a trailing `\r` into the next decode iteration. if self.trailing_cr: text = "\r" + text self.trailing_cr = False if text.endswith("\r"): self.trailing_cr = True text = text[:-1] if not text: # NOTE: the edge case input of empty text doesn't occur in practice, # because other httpx internals filter out this value return [] # pragma: no cover trailing_newline = text[-1] in "\n\r" lines = _splitlines_sse(text) if len(lines) == 1 and not trailing_newline: # No new lines, buffer the input and continue. self.buffer.append(lines[0]) return [] if self.buffer: # Include any existing buffer in the first portion of the # splitlines result. lines = ["".join(self.buffer) + lines[0]] + lines[1:] self.buffer = [] if not trailing_newline: # If the last segment of splitlines is not newline terminated, # then drop it from our output and start a new buffer. self.buffer = [lines.pop()] return lines def flush(self) -> list[str]: if not self.buffer and not self.trailing_cr: return [] lines = ["".join(self.buffer)] self.buffer = [] self.trailing_cr = False return lines class SSEDecoder: def __init__(self) -> None: self._event = "" self._data: List[str] = [] self._last_event_id = "" self._retry: Optional[int] = None def decode(self, line: str) -> Optional[ServerSentEvent]: # See: https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 if not line: if ( not self._event and not self._data and not self._last_event_id and self._retry is None ): return None sse = ServerSentEvent( event=self._event, data="\n".join(self._data), id=self._last_event_id, retry=self._retry, ) # NOTE: as per the SSE spec, do not reset last_event_id. self._event = "" self._data = [] self._retry = None return sse if line.startswith(":"): return None fieldname, _, value = line.partition(":") if value.startswith(" "): value = value[1:] if fieldname == "event": self._event = value elif fieldname == "data": self._data.append(value) elif fieldname == "id": if "\0" in value: pass else: self._last_event_id = value elif fieldname == "retry": try: self._retry = int(value) except (TypeError, ValueError): pass else: pass # Field is ignored. return None python-httpx-sse-0.4.3/src/httpx_sse/_exceptions.py000066400000000000000000000000761507227720300225060ustar00rootroot00000000000000import httpx class SSEError(httpx.TransportError): pass python-httpx-sse-0.4.3/src/httpx_sse/_models.py000066400000000000000000000023051507227720300216050ustar00rootroot00000000000000import json from typing import Any, Optional class ServerSentEvent: def __init__( self, event: Optional[str] = None, data: Optional[str] = None, id: Optional[str] = None, retry: Optional[int] = None, ) -> None: if not event: event = "message" if data is None: data = "" if id is None: id = "" self._event = event self._data = data self._id = id self._retry = retry @property def event(self) -> str: return self._event @property def data(self) -> str: return self._data @property def id(self) -> str: return self._id @property def retry(self) -> Optional[int]: return self._retry def json(self) -> Any: return json.loads(self.data) def __repr__(self) -> str: pieces = [f"event={self.event!r}"] if self.data != "": pieces.append(f"data={self.data!r}") if self.id != "": pieces.append(f"id={self.id!r}") if self.retry is not None: pieces.append(f"retry={self.retry!r}") return f"ServerSentEvent({', '.join(pieces)})" python-httpx-sse-0.4.3/src/httpx_sse/py.typed000066400000000000000000000000001507227720300212760ustar00rootroot00000000000000python-httpx-sse-0.4.3/tests/000077500000000000000000000000001507227720300161435ustar00rootroot00000000000000python-httpx-sse-0.4.3/tests/__init__.py000066400000000000000000000000001507227720300202420ustar00rootroot00000000000000python-httpx-sse-0.4.3/tests/test_api.py000066400000000000000000000073241507227720300203330ustar00rootroot00000000000000from typing import AsyncIterator, Iterator import httpx import pytest from httpx_sse import SSEError, aconnect_sse, connect_sse from httpx_sse._api import _aiter_sse_lines, _iter_sse_lines @pytest.mark.parametrize( "content_type", [ pytest.param("text/event-stream", id="exact"), pytest.param( "application/json, text/event-stream; charset=utf-8", id="contains" ), ], ) def test_connect_sse(content_type: str) -> None: def handler(request: httpx.Request) -> httpx.Response: if request.url.path == "/": return httpx.Response(200, text="Hello, world!") else: assert request.url.path == "/sse" text = "data: test\n\n" return httpx.Response( 200, headers={"content-type": content_type}, text=text ) with httpx.Client(transport=httpx.MockTransport(handler)) as client: response = client.request("GET", "http://testserver") assert response.status_code == 200 assert response.headers["content-type"] == "text/plain; charset=utf-8" with connect_sse(client, "GET", "http+sse://testserver/sse") as event_source: assert event_source.response.request.headers["cache-control"] == "no-store" def test_connect_sse_non_event_stream_received() -> None: def handler(request: httpx.Request) -> httpx.Response: assert request.url.path == "/" return httpx.Response(200, text="Hello, world!") with httpx.Client(transport=httpx.MockTransport(handler)) as client: with pytest.raises(SSEError, match="text/event-stream"): with connect_sse(client, "GET", "http://testserver") as event_source: for _ in event_source.iter_sse(): pass # pragma: no cover @pytest.mark.asyncio async def test_aconnect_sse() -> None: def handler(request: httpx.Request) -> httpx.Response: if request.url.path == "/": return httpx.Response(200, text="Hello, world!") else: assert request.url.path == "/sse" text = "data: test\n\n" return httpx.Response( 200, headers={"content-type": "text/event-stream"}, text=text ) async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client: response = await client.request("GET", "http://testserver") assert response.status_code == 200 assert response.headers["content-type"] == "text/plain; charset=utf-8" async with aconnect_sse( client, "GET", "http+sse://testserver/sse" ) as event_source: assert event_source.response.request.headers["cache-control"] == "no-store" def test_iter_sse_lines_basic() -> None: class Body(httpx.SyncByteStream): def __iter__(self) -> Iterator[bytes]: yield b"line1\nli" yield b"ne2\n" response = httpx.Response(200, stream=Body()) lines = list(_iter_sse_lines(response)) assert lines == ["line1", "line2"] def test_iter_sse_lines_with_flush() -> None: class Body(httpx.SyncByteStream): def __iter__(self) -> Iterator[bytes]: yield b"line1\npartial" response = httpx.Response(200, stream=Body()) lines = list(_iter_sse_lines(response)) assert lines == ["line1", "partial"] # flush gets the partial line @pytest.mark.asyncio async def test_aiter_sse_lines_with_flush() -> None: class AsyncBody(httpx.AsyncByteStream): async def __aiter__(self) -> AsyncIterator[bytes]: yield b"line1\nno_newline" response = httpx.Response(200, stream=AsyncBody()) lines = [line async for line in _aiter_sse_lines(response)] assert lines == ["line1", "no_newline"] # flush gets the partial line python-httpx-sse-0.4.3/tests/test_asgi.py000066400000000000000000000024651507227720300205060ustar00rootroot00000000000000from typing import AsyncIterator import httpx import pytest import pytest_asyncio from sse_starlette.sse import EventSourceResponse from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import Response from starlette.routing import Route from starlette.types import ASGIApp from httpx_sse import aconnect_sse @pytest.fixture def app() -> ASGIApp: async def auth_events(request: Request) -> Response: async def events() -> AsyncIterator[dict]: yield { "event": "login", "data": '{"user_id": "4135"}', } return EventSourceResponse(events()) return Starlette(routes=[Route("/sse/auth/", endpoint=auth_events)]) @pytest_asyncio.fixture async def client(app: ASGIApp) -> AsyncIterator[httpx.AsyncClient]: async with httpx.AsyncClient(transport=httpx.ASGITransport(app)) as client: # type: ignore[arg-type] yield client @pytest.mark.asyncio async def test_asgi_test(client: httpx.AsyncClient) -> None: async with aconnect_sse( client, "GET", "http://testserver/sse/auth/" ) as event_source: events = [sse async for sse in event_source.aiter_sse()] (sse,) = events assert sse.event == "login" assert sse.json() == {"user_id": "4135"} python-httpx-sse-0.4.3/tests/test_decoders.py000066400000000000000000000160531507227720300213510ustar00rootroot00000000000000from httpx_sse._decoders import SSELineDecoder, _splitlines_sse class TestSplitlinesSSE: def test_crlf_splitting(self) -> None: text = "line1\r\nline2\r\nline3" lines = _splitlines_sse(text) assert lines == ["line1", "line2", "line3"] def test_cr_splitting(self) -> None: text = "line1\rline2\rline3" lines = _splitlines_sse(text) assert lines == ["line1", "line2", "line3"] def test_lf_splitting(self) -> None: text = "line1\nline2\nline3" lines = _splitlines_sse(text) assert lines == ["line1", "line2", "line3"] def test_mixed_line_endings(self) -> None: text = "line1\r\nline2\nline3\rline4" lines = _splitlines_sse(text) assert lines == ["line1", "line2", "line3", "line4"] def test_empty_lines(self) -> None: text = "line1\n\nline3" lines = _splitlines_sse(text) assert lines == ["line1", "", "line3"] def test_unicode_line_separator_not_split(self) -> None: # U+2028 (LINE SEPARATOR) should NOT be treated as a newline text = "line1\u2028line2" lines = _splitlines_sse(text) assert lines == ["line1\u2028line2"] def test_unicode_paragraph_separator_not_split(self) -> None: # U+2029 (PARAGRAPH SEPARATOR) should NOT be treated as a newline text = "line1\u2029line2" lines = _splitlines_sse(text) assert lines == ["line1\u2029line2"] def test_unicode_next_line_not_split(self) -> None: # U+0085 (NEXT LINE) should NOT be treated as a newline text = "line1\u0085line2" lines = _splitlines_sse(text) assert lines == ["line1\u0085line2"] def test_empty_string(self) -> None: lines = _splitlines_sse("") assert lines == [] def test_only_newlines(self) -> None: lines = _splitlines_sse("\n\r\n\r") assert lines == ["", "", ""] def test_trailing_newlines(self) -> None: lines = _splitlines_sse("line1\n") assert lines == ["line1"] class TestSSELineDecoder: def _decode_chunks(self, chunks: list[str]) -> list[str]: """Helper to decode a list of chunks and return all lines.""" decoder = SSELineDecoder() lines = [] for chunk in chunks: lines.extend(decoder.decode(chunk)) lines.extend(decoder.flush()) return lines def test_basic_lines(self) -> None: chunks = ["line1\nline2\n"] assert self._decode_chunks(chunks) == ["line1", "line2"] def test_incremental_decoding(self) -> None: chunks = ["partial", " line\n", "another\n"] assert self._decode_chunks(chunks) == ["partial line", "another"] def test_trailing_cr_with_immediate_n(self) -> None: # \r at end of first chunk, \n at start of second chunk chunks = ["line1\r", "\nline2", "\n"] assert self._decode_chunks(chunks) == ["line1", "line2"] def test_crlf_across_chunks(self) -> None: # \r\n split across two chunks chunks = ["line1\r", "\nline2\n"] assert self._decode_chunks(chunks) == ["line1", "line2"] def test_buffer_without_newline(self) -> None: # Text without newline should be buffered then flushed chunks = ["buffered"] assert self._decode_chunks(chunks) == ["buffered"] def test_buffer_with_newline(self) -> None: # Text without newline followed by newline chunks = ["buffered", "\n"] assert self._decode_chunks(chunks) == ["buffered"] def test_no_flush_needed(self) -> None: # All lines terminated, flush returns nothing chunks = ["line1\n", "line2\n"] assert self._decode_chunks(chunks) == ["line1", "line2"] def test_flush_with_trailing_cr(self) -> None: # Text ending with \r should not leave buffered content after flush chunks = ["text\r"] assert self._decode_chunks(chunks) == ["text"] def test_empty_chunks(self) -> None: # Empty chunks should be handled gracefully chunks = ["", "line1\n", "", "line2\n", ""] assert self._decode_chunks(chunks) == ["line1", "line2"] def test_multiple_empty_lines(self) -> None: chunks = ["\n\n\n"] assert self._decode_chunks(chunks) == ["", "", ""] def test_mixed_line_endings_incremental(self) -> None: chunks = ["line1\r\n", "line2\r", "line3\n"] assert self._decode_chunks(chunks) == ["line1", "line2", "line3"] def test_partial_line_then_complete(self) -> None: chunks = ["par", "tial", " line\ncomp", "lete\n"] assert self._decode_chunks(chunks) == ["partial line", "complete"] def test_unicode_line_separators_preserved(self) -> None: # Unicode line separators should be preserved in the output chunks = ["data\u2028field\nline2\u2029end\n"] assert self._decode_chunks(chunks) == ["data\u2028field", "line2\u2029end"] def test_alternating_cr_lf(self) -> None: chunks = ["\r\n\r\n"] assert self._decode_chunks(chunks) == ["", ""] def test_flush_after_partial(self) -> None: chunks = ["line1\npartial"] assert self._decode_chunks(chunks) == ["line1", "partial"] def test_consecutive_cr_handling(self) -> None: chunks = ["line1\r\rline2\n"] assert self._decode_chunks(chunks) == ["line1", "", "line2"] def test_text_after_trailing_newline(self) -> None: chunks = ["line1\n", "line2", "\n"] assert self._decode_chunks(chunks) == ["line1", "line2"] def test_only_cr(self) -> None: chunks = ["\r", "\r"] assert self._decode_chunks(chunks) == ["", ""] def test_only_lf(self) -> None: chunks = ["\n"] assert self._decode_chunks(chunks) == [""] def test_empty_input(self) -> None: assert self._decode_chunks([]) == [] def test_single_char_chunks(self) -> None: # Test with single character chunks to ensure buffering works chunks = ["h", "e", "l", "l", "o", "\n", "w", "o", "r", "l", "d"] assert self._decode_chunks(chunks) == ["hello", "world"] def test_cr_lf_as_separate_chunks(self) -> None: # Each character as separate chunk chunks = ["l", "i", "n", "e", "1", "\r", "\n", "l", "i", "n", "e", "2"] assert self._decode_chunks(chunks) == ["line1", "line2"] def test_mixed_endings_with_content(self) -> None: chunks = ["a\rb\nc\r\nd"] assert self._decode_chunks(chunks) == ["a", "b", "c", "d"] def test_trailing_cr_no_followup(self) -> None: # Trailing \r with no following text chunks = ["line\r"] assert self._decode_chunks(chunks) == ["line"] def test_complex_mixed_scenario(self) -> None: # Complex scenario with various line endings and partial chunks chunks = [ "first", " line\r", "\nsecond", " line\r\n", "third\rfo", "urth\n", "fifth", ] assert self._decode_chunks(chunks) == [ "first line", "second line", "third", "fourth", "fifth", ] python-httpx-sse-0.4.3/tests/test_event_source.py000066400000000000000000000171461507227720300222660ustar00rootroot00000000000000from typing import AsyncIterator, Iterator import httpx import pytest from httpx_sse import EventSource # NOTE: the 'whatwg_example*' test cases are inspired by: # https://html.spec.whatwg.org/multipage/server-sent-events.html#event-stream-interpretation # noqa: E501 def test_iter_sse_whatwg_example1() -> None: class Body(httpx.SyncByteStream): def __iter__(self) -> Iterator[bytes]: yield b"data: YH00\n" yield b"data: +2\n" yield b"data: 10\n" yield b"\n" response = httpx.Response( 200, headers={"content-type": "text/event-stream"}, stream=Body(), ) events = list(EventSource(response).iter_sse()) assert len(events) == 1 assert events[0].event == "message" assert events[0].data == "YH00\n+2\n10" assert events[0].id == "" assert events[0].retry is None def test_iter_sse_whatwg_example2() -> None: class Body(httpx.SyncByteStream): def __iter__(self) -> Iterator[bytes]: yield b": test stream\n" yield b"\n" yield b"data: first event\n" yield b"id: 1\n" yield b"\n" yield b"data: second event\n" yield b"id\n" yield b"\n" yield b"data: third event\n" yield b"\n" response = httpx.Response( 200, headers={"content-type": "text/event-stream"}, stream=Body(), ) events = list(EventSource(response).iter_sse()) assert len(events) == 3 assert events[0].event == "message" assert events[0].data == "first event" assert events[0].id == "1" assert events[0].retry is None assert events[1].event == "message" assert events[1].data == "second event" assert events[1].id == "" assert events[1].retry is None assert events[2].event == "message" assert events[2].data == " third event" assert events[2].id == "" assert events[2].retry is None def test_iter_sse_whatwg_example3() -> None: class Body(httpx.SyncByteStream): def __iter__(self) -> Iterator[bytes]: yield b"data\n" yield b"\n" yield b"data\n" yield b"data\n" yield b"\n" yield b"data:\n" response = httpx.Response( 200, headers={"content-type": "text/event-stream"}, stream=Body(), ) events = list(EventSource(response).iter_sse()) assert len(events) == 2 assert events[0].event == "message" assert events[0].data == "" assert events[0].id == "" assert events[0].retry is None assert events[1].event == "message" assert events[1].data == "\n" assert events[1].id == "" assert events[1].retry is None def test_iter_sse_whatwg_example4() -> None: class Body(httpx.SyncByteStream): def __iter__(self) -> Iterator[bytes]: yield b"data:test\n" yield b"\n" yield b"data: test\n" yield b"\n" response = httpx.Response( 200, headers={"content-type": "text/event-stream"}, stream=Body(), ) events = list(EventSource(response).iter_sse()) assert len(events) == 2 assert events[0].event == "message" assert events[0].data == "test" assert events[0].id == "" assert events[0].retry is None assert events[1].event == "message" assert events[1].data == "test" assert events[1].id == "" assert events[1].retry is None def test_iter_sse_event() -> None: class Body(httpx.SyncByteStream): def __iter__(self) -> Iterator[bytes]: yield b"event: logline\n" yield b"data: New user connected\n" yield b"\n" response = httpx.Response( 200, headers={"content-type": "text/event-stream"}, stream=Body(), ) events = list(EventSource(response).iter_sse()) assert len(events) == 1 assert events[0].event == "logline" assert events[0].data == "New user connected" assert events[0].id == "" assert events[0].retry is None def test_iter_sse_id_null() -> None: class Body(httpx.SyncByteStream): def __iter__(self) -> Iterator[bytes]: yield b"data: test\n" yield b"id: 123\0\n" yield b"\n" response = httpx.Response( 200, headers={"content-type": "text/event-stream"}, stream=Body(), ) events = list(EventSource(response).iter_sse()) assert len(events) == 1 assert events[0].event == "message" assert events[0].data == "test" assert events[0].id == "" assert events[0].retry is None def test_iter_sse_id_retry() -> None: class Body(httpx.SyncByteStream): def __iter__(self) -> Iterator[bytes]: yield b"retry: 10000\n" yield b"\n" response = httpx.Response( 200, headers={"content-type": "text/event-stream"}, stream=Body(), ) events = list(EventSource(response).iter_sse()) assert len(events) == 1 assert events[0].event == "message" assert events[0].data == "" assert events[0].id == "" assert events[0].retry == 10000 def test_iter_sse_id_retry_invalid() -> None: class Body(httpx.SyncByteStream): def __iter__(self) -> Iterator[bytes]: yield b"retry: 1667a\n" yield b"\n" response = httpx.Response( 200, headers={"content-type": "text/event-stream"}, stream=Body(), ) events = list(EventSource(response).iter_sse()) assert len(events) == 0 def test_iter_sse_unknown_field() -> None: class Body(httpx.SyncByteStream): def __iter__(self) -> Iterator[bytes]: yield b"something: ignore\n" yield b"\n" response = httpx.Response( 200, headers={"content-type": "text/event-stream"}, stream=Body(), ) events = list(EventSource(response).iter_sse()) assert len(events) == 0 def test_unicode_line_separator_not_parsed_as_newline() -> None: """Test that Unicode line separator (U+2028) is not parsed as a newline. The SSE spec only allows CR, LF, or CRLF as line separators. Other Unicode newline characters should be preserved in the data. See: https://github.com/florimondmanca/httpx-sse/issues/34 """ class Body(httpx.SyncByteStream): def __iter__(self) -> Iterator[bytes]: # U+2028 is LINE SEPARATOR in UTF-8 encoding: \xe2\x80\xa8 yield b"event: message\n" yield b'data: {"text":"Hello\xe2\x80\xa8World"}\n' yield b"\n" response = httpx.Response( 200, headers={"content-type": "text/event-stream"}, stream=Body(), ) events = list(EventSource(response).iter_sse()) assert len(events) == 1 assert events[0].event == "message" # The line separator should be preserved in the data, not treated as a line break assert events[0].data == '{"text":"Hello\u2028World"}' assert events[0].id == "" assert events[0].retry is None @pytest.mark.asyncio async def test_aiter_sse() -> None: class AsyncBody(httpx.AsyncByteStream): async def __aiter__(self) -> AsyncIterator[bytes]: yield b"data: YH00\n" yield b"data: +2\n" yield b"data: 10\n" yield b"\n" response = httpx.Response( 200, headers={"content-type": "text/event-stream"}, stream=AsyncBody(), ) events = [sse async for sse in EventSource(response).aiter_sse()] assert len(events) == 1 assert events[0].event == "message" assert events[0].data == "YH00\n+2\n10" assert events[0].id == "" assert events[0].retry is None python-httpx-sse-0.4.3/tests/test_exceptions.py000066400000000000000000000002031507227720300217300ustar00rootroot00000000000000import httpx from httpx_sse import SSEError def test_sse_error() -> None: assert issubclass(SSEError, httpx.TransportError) python-httpx-sse-0.4.3/tests/test_models.py000066400000000000000000000015061507227720300210410ustar00rootroot00000000000000import json import pytest from httpx_sse import ServerSentEvent def test_sse_default() -> None: sse = ServerSentEvent() assert sse.event == "message" assert sse.data == "" assert sse.id == "" assert sse.retry is None def test_sse_json() -> None: sse = ServerSentEvent() with pytest.raises(json.JSONDecodeError): sse.json() sse = ServerSentEvent(data='{"key": "value"}') assert sse.json() == {"key": "value"} sse = ServerSentEvent(data='["item1", "item2"]') assert sse.json() == ["item1", "item2"] def test_sse_repr() -> None: sse = ServerSentEvent() assert repr(sse) == "ServerSentEvent(event='message')" sse = ServerSentEvent(data="data", retry=3, id="id", event="event") assert repr(sse) == "ServerSentEvent(event='event', data='data', id='id', retry=3)"