pax_global_header00006660000000000000000000000064145412405050014512gustar00rootroot0000000000000052 comment=1b8f7f0f6a1ecca36d1a2f839f306c592e93db34 httpx-sse-0.4.0/000077500000000000000000000000001454124050500134525ustar00rootroot00000000000000httpx-sse-0.4.0/.gitignore000066400000000000000000000002161454124050500154410ustar00rootroot00000000000000# Tooling. .coverage venv*/ # Caches. __pycache__/ *.pyc .mypy_cache/ .pytest_cache/ # Packaging. build/ dist/ *.egg-info/ # Private. .env httpx-sse-0.4.0/CHANGELOG.md000066400000000000000000000025471454124050500152730ustar00rootroot00000000000000# 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.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`. httpx-sse-0.4.0/LICENSE000066400000000000000000000020601454124050500144550ustar00rootroot00000000000000MIT 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. httpx-sse-0.4.0/MANIFEST.in000066400000000000000000000001011454124050500152000ustar00rootroot00000000000000graft src include README.md include CHANGELOG.md include LICENSE httpx-sse-0.4.0/Makefile000066400000000000000000000007461454124050500151210ustar00rootroot00000000000000venv = 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 httpx-sse-0.4.0/README.md000066400000000000000000000152551454124050500147410ustar00rootroot00000000000000# 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(app=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). #### `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 httpx-sse-0.4.0/ci/000077500000000000000000000000001454124050500140455ustar00rootroot00000000000000httpx-sse-0.4.0/ci/azure-pipelines.yml000066400000000000000000000015751454124050500177140ustar00rootroot00000000000000resources: repositories: - repository: templates type: github endpoint: github name: florimondmanca/azure-pipelines-templates ref: refs/tags/6.2 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.12" - template: job--python-test.yml@templates parameters: jobs: py38: py312: coverage: true - stage: publish condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/') jobs: - template: job--python-publish.yml@templates parameters: pythonVersion: "3.12" token: $(pypiToken) httpx-sse-0.4.0/pyproject.toml000066400000000000000000000021071454124050500163660ustar00rootroot00000000000000[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.8" 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.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", ] 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"] httpx-sse-0.4.0/requirements.txt000066400000000000000000000004711454124050500167400ustar00rootroot00000000000000-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 httpx-sse-0.4.0/setup.cfg000066400000000000000000000003061454124050500152720ustar00rootroot00000000000000[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 httpx-sse-0.4.0/setup.py000066400000000000000000000000741454124050500151650ustar00rootroot00000000000000from setuptools import setup setup() # Editable installs. httpx-sse-0.4.0/src/000077500000000000000000000000001454124050500142415ustar00rootroot00000000000000httpx-sse-0.4.0/src/httpx_sse/000077500000000000000000000000001454124050500162625ustar00rootroot00000000000000httpx-sse-0.4.0/src/httpx_sse/__init__.py000066400000000000000000000004321454124050500203720ustar00rootroot00000000000000from ._api import EventSource, aconnect_sse, connect_sse from ._exceptions import SSEError from ._models import ServerSentEvent __version__ = "0.4.0" __all__ = [ "__version__", "EventSource", "connect_sse", "aconnect_sse", "ServerSentEvent", "SSEError", ] httpx-sse-0.4.0/src/httpx_sse/_api.py000066400000000000000000000042021454124050500175420ustar00rootroot00000000000000from contextlib import asynccontextmanager, contextmanager from typing import Any, AsyncIterator, Iterator import httpx from ._decoders import SSEDecoder 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 self._response.iter_lines(): line = line.rstrip("\n") sse = decoder.decode(line) if sse is not None: yield sse async def aiter_sse(self) -> AsyncIterator[ServerSentEvent]: self._check_content_type() decoder = SSEDecoder() async for line in self._response.aiter_lines(): line = line.rstrip("\n") sse = decoder.decode(line) if sse is not None: yield sse @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) httpx-sse-0.4.0/src/httpx_sse/_decoders.py000066400000000000000000000033241454124050500205650ustar00rootroot00000000000000from typing import List, Optional from ._models import ServerSentEvent 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 httpx-sse-0.4.0/src/httpx_sse/_exceptions.py000066400000000000000000000000761454124050500211570ustar00rootroot00000000000000import httpx class SSEError(httpx.TransportError): pass httpx-sse-0.4.0/src/httpx_sse/_models.py000066400000000000000000000023051454124050500202560ustar00rootroot00000000000000import 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)})" httpx-sse-0.4.0/src/httpx_sse/py.typed000066400000000000000000000000001454124050500177470ustar00rootroot00000000000000httpx-sse-0.4.0/tests/000077500000000000000000000000001454124050500146145ustar00rootroot00000000000000httpx-sse-0.4.0/tests/__init__.py000066400000000000000000000000001454124050500167130ustar00rootroot00000000000000httpx-sse-0.4.0/tests/test_api.py000066400000000000000000000050761454124050500170060ustar00rootroot00000000000000import httpx import pytest from httpx_sse import SSEError, aconnect_sse, connect_sse @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" httpx-sse-0.4.0/tests/test_asgi.py000066400000000000000000000024001454124050500171440ustar00rootroot00000000000000from 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(app=app) as client: 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"} httpx-sse-0.4.0/tests/test_event_source.py000066400000000000000000000150371454124050500207340ustar00rootroot00000000000000from 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 @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 httpx-sse-0.4.0/tests/test_exceptions.py000066400000000000000000000002031454124050500204010ustar00rootroot00000000000000import httpx from httpx_sse import SSEError def test_sse_error() -> None: assert issubclass(SSEError, httpx.TransportError) httpx-sse-0.4.0/tests/test_models.py000066400000000000000000000015061454124050500175120ustar00rootroot00000000000000import 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)"