Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-httpx2 for openSUSE:Factory checked in at 2026-07-06 12:27:15 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-httpx2 (Old) and /work/SRC/openSUSE:Factory/.python-httpx2.new.1982 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-httpx2" Mon Jul 6 12:27:15 2026 rev:3 rq:1362975 version:2.5.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-httpx2/python-httpx2.changes 2026-06-18 18:38:37.872042446 +0200 +++ /work/SRC/openSUSE:Factory/.python-httpx2.new.1982/python-httpx2.changes 2026-07-06 12:27:31.597385674 +0200 @@ -1,0 +2,6 @@ +Wed Jul 1 12:02:35 UTC 2026 - Dirk Müller <[email protected]> + +- update to 2.5.0: + * Propagate the timeout through the SOCKS5 handshake. + +------------------------------------------------------------------- Old: ---- httpx2-2.4.0.tar.gz New: ---- httpx2-2.5.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-httpx2.spec ++++++ --- /var/tmp/diff_new_pack.6R3Kvk/_old 2026-07-06 12:27:32.393413283 +0200 +++ /var/tmp/diff_new_pack.6R3Kvk/_new 2026-07-06 12:27:32.397413422 +0200 @@ -24,7 +24,7 @@ %{?sle15_python_module_pythons} Name: python-httpx2 -Version: 2.4.0 +Version: 2.5.0 Release: 0 Summary: The next generation HTTP client License: BSD-3-Clause ++++++ httpx2-2.4.0.tar.gz -> httpx2-2.5.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/docs/api.md new/httpx2-2.5.0/docs/api.md --- old/httpx2-2.4.0/docs/api.md 2026-06-11 08:29:52.000000000 +0200 +++ new/httpx2-2.5.0/docs/api.md 2026-06-25 16:14:41.000000000 +0200 @@ -44,6 +44,7 @@ - patch - delete - stream + - sse - build_request - send - close @@ -66,6 +67,7 @@ - patch - delete - stream + - sse - build_request - send - aclose @@ -197,3 +199,21 @@ * `.auth` - **tuple[str, str]** * `.headers` - **Headers** * `.ssl_context` - **SSLContext** + +## `EventSource` + +::: httpx2.EventSource + options: + members: + - response + +## `ServerSentEvent` + +::: httpx2.ServerSentEvent + options: + members: + - event + - data + - id + - retry + - json diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/docs/exceptions.md new/httpx2-2.5.0/docs/exceptions.md --- old/httpx2-2.4.0/docs/exceptions.md 2026-06-11 08:29:52.000000000 +0200 +++ new/httpx2-2.5.0/docs/exceptions.md 2026-06-25 16:14:41.000000000 +0200 @@ -24,6 +24,7 @@ * RemoteProtocolError * ProxyError * UnsupportedProtocol + * SSEError * DecodingError * TooManyRedirects * HTTPStatusError @@ -75,6 +76,8 @@ ::: httpx2.UnsupportedProtocol +::: httpx2.SSEError + ::: httpx2.DecodingError ::: httpx2.TooManyRedirects diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/docs/sse.md new/httpx2-2.5.0/docs/sse.md --- old/httpx2-2.4.0/docs/sse.md 1970-01-01 01:00:00.000000000 +0100 +++ new/httpx2-2.5.0/docs/sse.md 2026-06-25 16:14:41.000000000 +0200 @@ -0,0 +1,77 @@ +# Server-Sent Events + +[Server-sent events][mdn] (SSE) let a server push a stream of events to the client over a single long-lived HTTP response with the `text/event-stream` content type. HTTPX has native support for consuming them through `client.sse()`. + +## Consuming events + +`client.sse()` is a context manager that yields an `EventSource`. Iterating the `EventSource` decodes the stream and yields a `ServerSentEvent` for each event: + +```pycon +>>> with httpx2.Client() as client: +... with client.sse("https://example.com/sse") as source: +... for event in source: +... print(event.event, event.data) +``` + +It works the same way with the async client, using `async with` and `async for`: + +```pycon +>>> async with httpx2.AsyncClient() as client: +... async with client.sse("https://example.com/sse") as source: +... async for event in source: +... print(event.event, event.data) +``` + +`sse()` issues a `GET` request by default. Some APIs stream events in response to a `POST` - pass `method="POST"` along with the usual request arguments: + +```pycon +>>> with client.sse("https://example.com/sse", method="POST", json={"query": "..."}) as source: +... for event in source: +... print(event.data) +``` + +The `Accept: text/event-stream` and `Cache-Control: no-store` headers are set for you; any headers you pass take precedence. + +## The `ServerSentEvent` + +Each event exposes the fields defined by the SSE specification: + +| Attribute | Description | +| --------- | ----------- | +| `event` | The event type. Defaults to `"message"`. | +| `data` | The event payload. Multiple `data:` lines are joined with `\n`. | +| `id` | The last event ID, which persists across events until changed. | +| `retry` | The reconnection time in milliseconds, or `None`. | + +When the payload is JSON, `event.json()` decodes `event.data` for you: + +```pycon +>>> with client.sse("https://example.com/sse") as source: +... for event in source: +... print(event.json()) +``` + +## Accessing the response + +The underlying [`Response`](api.md#response) is available as `source.response`, which is useful for inspecting the status code or headers before iterating: + +```pycon +>>> with client.sse("https://example.com/sse") as source: +... source.response.raise_for_status() +... for event in source: +... print(event.data) +``` + +## Error handling + +If the response does not have a `text/event-stream` content type, iterating the `EventSource` raises `SSEError`: + +```pycon +>>> with client.sse("https://example.com/not-an-event-stream") as source: +... for event in source: # raises httpx2.SSEError +... ... +``` + +`SSEError` is a subclass of [`TransportError`](exceptions.md), so it is also caught by `except httpx2.TransportError`. + +[mdn]: https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/mkdocs.yml new/httpx2-2.5.0/mkdocs.yml --- old/httpx2-2.4.0/mkdocs.yml 2026-06-11 08:29:52.000000000 +0200 +++ new/httpx2-2.5.0/mkdocs.yml 2026-06-25 16:14:41.000000000 +0200 @@ -38,6 +38,7 @@ - Async Support: 'async.md' - HTTP/2 Support: 'http2.md' - Logging: 'logging.md' + - Server-Sent Events: 'sse.md' - Requests Compatibility: 'compatibility.md' - Troubleshooting: 'troubleshooting.md' - API Reference: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/src/httpcore2/CHANGELOG.md new/httpx2-2.5.0/src/httpcore2/CHANGELOG.md --- old/httpx2-2.4.0/src/httpcore2/CHANGELOG.md 2026-06-11 08:29:52.000000000 +0200 +++ new/httpx2-2.5.0/src/httpcore2/CHANGELOG.md 2026-06-25 16:14:41.000000000 +0200 @@ -4,6 +4,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## 2.5.0 (June 25th, 2026) + +### Fixed + +* Propagate the timeout through the SOCKS5 handshake. ([#1009](https://github.com/pydantic/httpx2/pull/1009)) + ## 2.4.0 (June 11th, 2026) ### Fixed diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/src/httpcore2/httpcore2/_async/socks_proxy.py new/httpx2-2.5.0/src/httpcore2/httpcore2/_async/socks_proxy.py --- old/httpx2-2.4.0/src/httpcore2/httpcore2/_async/socks_proxy.py 2026-06-11 08:29:52.000000000 +0200 +++ new/httpx2-2.5.0/src/httpcore2/httpcore2/_async/socks_proxy.py 2026-06-25 16:14:41.000000000 +0200 @@ -45,7 +45,11 @@ host: bytes, port: int, auth: tuple[bytes, bytes] | None = None, + timeouts: dict[str, float | None] | None = None, ) -> None: + timeouts = timeouts or {} + write_timeout = timeouts.get("write", None) + read_timeout = timeouts.get("read", None) conn = socksio.socks5.SOCKS5Connection() # Auth method request @@ -56,10 +60,10 @@ ) conn.send(socksio.socks5.SOCKS5AuthMethodsRequest([auth_method])) outgoing_bytes = conn.data_to_send() - await stream.write(outgoing_bytes) + await stream.write(outgoing_bytes, timeout=write_timeout) # Auth method response - incoming_bytes = await stream.read(max_bytes=4096) + incoming_bytes = await stream.read(max_bytes=4096, timeout=read_timeout) response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5AuthReply) if response.method != auth_method: @@ -73,10 +77,10 @@ username, password = auth conn.send(socksio.socks5.SOCKS5UsernamePasswordRequest(username, password)) outgoing_bytes = conn.data_to_send() - await stream.write(outgoing_bytes) + await stream.write(outgoing_bytes, timeout=write_timeout) # Username/password response - incoming_bytes = await stream.read(max_bytes=4096) + incoming_bytes = await stream.read(max_bytes=4096, timeout=read_timeout) response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5UsernamePasswordReply) if not response.success: @@ -85,10 +89,10 @@ # Connect request conn.send(socksio.socks5.SOCKS5CommandRequest.from_address(socksio.socks5.SOCKS5Command.CONNECT, (host, port))) outgoing_bytes = conn.data_to_send() - await stream.write(outgoing_bytes) + await stream.write(outgoing_bytes, timeout=write_timeout) # Connect response - incoming_bytes = await stream.read(max_bytes=4096) + incoming_bytes = await stream.read(max_bytes=4096, timeout=read_timeout) response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5Reply) if response.reply_code != socksio.socks5.SOCKS5ReplyCode.SUCCEEDED: @@ -229,6 +233,7 @@ "host": self._remote_origin.host.decode("ascii"), "port": self._remote_origin.port, "auth": self._proxy_auth, + "timeouts": timeouts, } async with Trace("setup_socks5_connection", logger, request, kwargs) as trace: await _init_socks5_connection(**kwargs) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/src/httpcore2/httpcore2/_sync/socks_proxy.py new/httpx2-2.5.0/src/httpcore2/httpcore2/_sync/socks_proxy.py --- old/httpx2-2.4.0/src/httpcore2/httpcore2/_sync/socks_proxy.py 2026-06-11 08:29:52.000000000 +0200 +++ new/httpx2-2.5.0/src/httpcore2/httpcore2/_sync/socks_proxy.py 2026-06-25 16:14:41.000000000 +0200 @@ -45,7 +45,11 @@ host: bytes, port: int, auth: tuple[bytes, bytes] | None = None, + timeouts: dict[str, float | None] | None = None, ) -> None: + timeouts = timeouts or {} + write_timeout = timeouts.get("write", None) + read_timeout = timeouts.get("read", None) conn = socksio.socks5.SOCKS5Connection() # Auth method request @@ -56,10 +60,10 @@ ) conn.send(socksio.socks5.SOCKS5AuthMethodsRequest([auth_method])) outgoing_bytes = conn.data_to_send() - stream.write(outgoing_bytes) + stream.write(outgoing_bytes, timeout=write_timeout) # Auth method response - incoming_bytes = stream.read(max_bytes=4096) + incoming_bytes = stream.read(max_bytes=4096, timeout=read_timeout) response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5AuthReply) if response.method != auth_method: @@ -73,10 +77,10 @@ username, password = auth conn.send(socksio.socks5.SOCKS5UsernamePasswordRequest(username, password)) outgoing_bytes = conn.data_to_send() - stream.write(outgoing_bytes) + stream.write(outgoing_bytes, timeout=write_timeout) # Username/password response - incoming_bytes = stream.read(max_bytes=4096) + incoming_bytes = stream.read(max_bytes=4096, timeout=read_timeout) response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5UsernamePasswordReply) if not response.success: @@ -85,10 +89,10 @@ # Connect request conn.send(socksio.socks5.SOCKS5CommandRequest.from_address(socksio.socks5.SOCKS5Command.CONNECT, (host, port))) outgoing_bytes = conn.data_to_send() - stream.write(outgoing_bytes) + stream.write(outgoing_bytes, timeout=write_timeout) # Connect response - incoming_bytes = stream.read(max_bytes=4096) + incoming_bytes = stream.read(max_bytes=4096, timeout=read_timeout) response = conn.receive_data(incoming_bytes) assert isinstance(response, socksio.socks5.SOCKS5Reply) if response.reply_code != socksio.socks5.SOCKS5ReplyCode.SUCCEEDED: @@ -229,6 +233,7 @@ "host": self._remote_origin.host.decode("ascii"), "port": self._remote_origin.port, "auth": self._proxy_auth, + "timeouts": timeouts, } with Trace("setup_socks5_connection", logger, request, kwargs) as trace: _init_socks5_connection(**kwargs) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/src/httpx2/CHANGELOG.md new/httpx2-2.5.0/src/httpx2/CHANGELOG.md --- old/httpx2-2.4.0/src/httpx2/CHANGELOG.md 2026-06-11 08:29:52.000000000 +0200 +++ new/httpx2-2.5.0/src/httpx2/CHANGELOG.md 2026-06-25 16:14:41.000000000 +0200 @@ -4,6 +4,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## 2.5.0 (June 25th, 2026) + +### Added + +* Add native server-sent events support via `client.sse()`. ([#1046](https://github.com/pydantic/httpx2/pull/1046)) +* Support `|` and `|=` operators for `Headers`. ([#1047](https://github.com/pydantic/httpx2/pull/1047)) + +### Fixed + +* Allow IPv6 CIDR notation in `no_proxy`. ([#967](https://github.com/pydantic/httpx2/pull/967)) + ## 2.4.0 (June 11th, 2026) ### Added diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/src/httpx2/httpx2/__init__.py new/httpx2-2.5.0/src/httpx2/httpx2/__init__.py --- old/httpx2-2.4.0/src/httpx2/httpx2/__init__.py 2026-06-11 08:29:52.000000000 +0200 +++ new/httpx2-2.5.0/src/httpx2/httpx2/__init__.py 2026-06-25 16:14:41.000000000 +0200 @@ -6,6 +6,7 @@ from ._content import * from ._exceptions import * from ._models import * +from ._sse import * from ._status_codes import * from ._transports import * from ._types import * @@ -35,6 +36,7 @@ "DecodingError", "delete", "DigestAuth", + "EventSource", "FunctionAuth", "get", "head", @@ -66,6 +68,8 @@ "RequestNotRead", "Response", "ResponseNotRead", + "ServerSentEvent", + "SSEError", "stream", "StreamClosed", "StreamConsumed", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/src/httpx2/httpx2/_client.py new/httpx2-2.5.0/src/httpx2/httpx2/_client.py --- old/httpx2-2.4.0/src/httpx2/httpx2/_client.py 2026-06-11 08:29:52.000000000 +0200 +++ new/httpx2-2.5.0/src/httpx2/httpx2/_client.py 2026-06-25 16:14:41.000000000 +0200 @@ -28,6 +28,7 @@ request_context, ) from ._models import Cookies, Headers, Request, Response +from ._sse import EventSource from ._status_codes import codes from ._transports.base import AsyncBaseTransport, BaseTransport from ._transports.default import AsyncHTTPTransport, HTTPTransport @@ -845,6 +846,48 @@ finally: response.close() + @contextmanager + def sse( + self, + url: URL | str, + *, + method: str = "GET", + content: RequestContent | None = None, + data: RequestData | None = None, + files: RequestFiles | None = None, + json: typing.Any | None = None, + params: QueryParamTypes | None = None, + headers: HeaderTypes | None = None, + cookies: CookieTypes | None = None, + auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT, + follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, + timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, + extensions: RequestExtensions | None = None, + ) -> Generator[EventSource]: + """ + Connect to a server-sent events endpoint and yield an `EventSource`. + + Iterating the `EventSource` yields `ServerSentEvent` instances. + + **Parameters**: See `httpx2.request`. + """ + with self.stream( + method, + url, + content=content, + data=data, + files=files, + json=json, + params=params, + headers={"Accept": "text/event-stream", "Cache-Control": "no-store"} | Headers(headers), + cookies=cookies, + auth=auth, + follow_redirects=follow_redirects, + timeout=timeout, + extensions=extensions, + ) as response: + yield EventSource(response) + def send( self, request: Request, @@ -1548,6 +1591,48 @@ finally: await response.aclose() + @asynccontextmanager + async def sse( + self, + url: URL | str, + *, + method: str = "GET", + content: RequestContent | None = None, + data: RequestData | None = None, + files: RequestFiles | None = None, + json: typing.Any | None = None, + params: QueryParamTypes | None = None, + headers: HeaderTypes | None = None, + cookies: CookieTypes | None = None, + auth: AuthTypes | UseClientDefault | None = USE_CLIENT_DEFAULT, + follow_redirects: bool | UseClientDefault = USE_CLIENT_DEFAULT, + timeout: TimeoutTypes | UseClientDefault = USE_CLIENT_DEFAULT, + extensions: RequestExtensions | None = None, + ) -> AsyncGenerator[EventSource]: + """ + Connect to a server-sent events endpoint and yield an `EventSource`. + + Iterating the `EventSource` yields `ServerSentEvent` instances. + + **Parameters**: See `httpx2.request`. + """ + async with self.stream( + method, + url, + content=content, + data=data, + files=files, + json=json, + params=params, + headers={"Accept": "text/event-stream", "Cache-Control": "no-store"} | Headers(headers), + cookies=cookies, + auth=auth, + follow_redirects=follow_redirects, + timeout=timeout, + extensions=extensions, + ) as response: + yield EventSource(response) + async def send( self, request: Request, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/src/httpx2/httpx2/_models.py new/httpx2-2.5.0/src/httpx2/httpx2/_models.py --- old/httpx2-2.4.0/src/httpx2/httpx2/_models.py 2026-06-11 08:29:52.000000000 +0200 +++ new/httpx2-2.5.0/src/httpx2/httpx2/_models.py 2026-06-25 16:14:41.000000000 +0200 @@ -277,6 +277,24 @@ def copy(self) -> Headers: return Headers(self, encoding=self.encoding) + def __or__(self, other: Mapping[str, str]) -> Headers: + if not isinstance(other, Mapping): + return NotImplemented + merged = self.copy() + merged.update(other) + return merged + + def __ror__(self, other: Mapping[str, str]) -> Headers: + if not isinstance(other, Mapping): + return NotImplemented + merged = Headers(other) + merged.update(self) + return merged + + def __ior__(self, other: HeaderTypes) -> Headers: + self.update(other) + return self + def __getitem__(self, key: str) -> str: """ Return a single header value. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/src/httpx2/httpx2/_sse.py new/httpx2-2.5.0/src/httpx2/httpx2/_sse.py --- old/httpx2-2.4.0/src/httpx2/httpx2/_sse.py 1970-01-01 01:00:00.000000000 +0100 +++ new/httpx2-2.5.0/src/httpx2/httpx2/_sse.py 2026-06-25 16:14:41.000000000 +0200 @@ -0,0 +1,158 @@ +""" +Server-sent events support, derived from httpx-sse (https://github.com/florimondmanca/httpx-sse). + +Copyright (c) 2022 Florimond Manca, MIT License (https://github.com/florimondmanca/httpx-sse/blob/master/LICENSE). +""" + +from __future__ import annotations + +import json as jsonlib +from collections.abc import AsyncIterator, Iterator +from dataclasses import dataclass + +from ._exceptions import TransportError +from ._models import Response + +__all__ = ["EventSource", "SSEError", "ServerSentEvent"] + + +class SSEError(TransportError): + """ + An error that occurred while connecting to a server-sent events endpoint. + """ + + +@dataclass(frozen=True) +class ServerSentEvent: + event: str = "message" + data: str = "" + id: str = "" + retry: int | None = None + + def json(self) -> object: + return jsonlib.loads(self.data) + + +class _SSEDecoder: + def __init__(self) -> None: + self._event = "" + self._data: list[str] = [] + self._last_event_id = "" + self._retry: int | None = None + self._pending = False + + def decode(self, line: str) -> ServerSentEvent | None: + if not line: + if not self._pending: + return None + + sse = ServerSentEvent( + event=self._event or "message", + data="\n".join(self._data), + id=self._last_event_id, + retry=self._retry, + ) + self._event = "" + self._data = [] + self._retry = None + self._pending = False + return sse + + if line.startswith(":"): + return None + + fieldname, _, value = line.partition(":") + value = value[1:] if value.startswith(" ") else value + + if fieldname == "event": + self._event = value + self._pending = True + elif fieldname == "data": + self._data.append(value) + self._pending = True + elif fieldname == "id": + if "\0" not in value: + self._last_event_id = value + self._pending = True + elif fieldname == "retry": + try: + self._retry = int(value) + self._pending = True + except ValueError: + pass + + return None + + +class _SSELineDecoder: + def __init__(self) -> None: + self._buffer = "" + self._trailing_cr = False + + def decode(self, text: str) -> list[str]: + if self._trailing_cr: + text = "\r" + text + self._trailing_cr = False + if text.endswith("\r"): + self._trailing_cr = True + text = text[:-1] + + text = self._buffer + text.replace("\r\n", "\n").replace("\r", "\n") + lines = text.split("\n") + self._buffer = lines.pop() + return lines + + def flush(self) -> list[str]: + if self._trailing_cr: + self._buffer += "\n" + self._trailing_cr = False + if not self._buffer: + return [] + lines = self._buffer.split("\n") + self._buffer = "" + return lines + + +class EventSource: + def __init__(self, response: Response) -> None: + self._response = response + + @property + def response(self) -> Response: + return self._response + + def _check_content_type(self) -> None: + content_type, _, _ = self._response.headers.get("content-type", "").partition(";") + if content_type.strip().lower() != "text/event-stream": + raise SSEError( + f"Expected response with content type 'text/event-stream', got {content_type.strip()!r}.", + request=self._response.request, + ) + + def __iter__(self) -> Iterator[ServerSentEvent]: + self._check_content_type() + decoder = _SSEDecoder() + lines = _SSELineDecoder() + for chunk in self._response.iter_text(): + for line in lines.decode(chunk): + sse = decoder.decode(line) + if sse is not None: + yield sse + for line in lines.flush(): + sse = decoder.decode(line) + if sse is not None: + yield sse + + async def __aiter__(self) -> AsyncIterator[ServerSentEvent]: + self._check_content_type() + decoder = _SSEDecoder() + lines = _SSELineDecoder() + async for chunk in self._response.aiter_text(): + for line in lines.decode(chunk): + sse = decoder.decode(line) + if sse is not None: + yield sse + for line in lines.flush(): + sse = decoder.decode(line) + if sse is not None: + yield sse diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/src/httpx2/httpx2/_utils.py new/httpx2-2.5.0/src/httpx2/httpx2/_utils.py --- old/httpx2-2.4.0/src/httpx2/httpx2/_utils.py 2026-06-11 08:29:52.000000000 +0200 +++ new/httpx2-2.5.0/src/httpx2/httpx2/_utils.py 2026-06-25 16:14:41.000000000 +0200 @@ -65,7 +65,11 @@ elif is_ipv4_hostname(hostname): mounts[f"all://{hostname}"] = None elif is_ipv6_hostname(hostname): - mounts[f"all://[{hostname}]"] = None + if "/" in hostname: + addr, _, subnet = hostname.partition("/") + mounts[f"all://[{addr}]/{subnet}"] = None + else: + mounts[f"all://[{hostname}]"] = None elif hostname.lower() == "localhost": mounts[f"all://{hostname}"] = None else: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/tests/httpx2/models/test_headers.py new/httpx2-2.5.0/tests/httpx2/models/test_headers.py --- old/httpx2-2.4.0/tests/httpx2/models/test_headers.py 2026-06-11 08:29:52.000000000 +0200 +++ new/httpx2-2.5.0/tests/httpx2/models/test_headers.py 2026-06-25 16:14:41.000000000 +0200 @@ -65,6 +65,46 @@ assert headers == headers_copy +def test_headers_or() -> None: + left = httpx2.Headers({"a": "1", "b": "2"}) + merged = left | {"b": "3", "c": "4"} + assert isinstance(merged, httpx2.Headers) + assert dict(merged) == {"a": "1", "b": "3", "c": "4"} + assert dict(left) == {"a": "1", "b": "2"} + + +def test_headers_or_with_headers() -> None: + merged = httpx2.Headers({"a": "1"}) | httpx2.Headers({"a": "2"}) + assert dict(merged) == {"a": "2"} + + +def test_headers_ror() -> None: + merged = {"a": "1", "b": "2"} | httpx2.Headers({"b": "3"}) + assert isinstance(merged, httpx2.Headers) + assert dict(merged) == {"a": "1", "b": "3"} + + +def test_headers_ior() -> None: + headers = httpx2.Headers({"a": "1"}) + original = headers + headers |= {"a": "2", "b": "3"} + assert headers is original + assert dict(headers) == {"a": "2", "b": "3"} + + +def test_headers_ior_accepts_update_inputs() -> None: + headers = httpx2.Headers({"a": "1"}) + headers |= [("a", "2"), ("b", "3")] + assert dict(headers) == {"a": "2", "b": "3"} + + +def test_headers_or_unsupported_type() -> None: + with pytest.raises(TypeError): + httpx2.Headers({"a": "1"}) | [("b", "2")] # type: ignore[operator] + with pytest.raises(TypeError): + [("b", "2")] | httpx2.Headers({"a": "1"}) # type: ignore[operator] + + def test_headers_insert_retains_ordering() -> None: headers = httpx2.Headers({"a": "a", "b": "b", "c": "c"}) headers["b"] = "123" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/tests/httpx2/test_sse.py new/httpx2-2.5.0/tests/httpx2/test_sse.py --- old/httpx2-2.4.0/tests/httpx2/test_sse.py 1970-01-01 01:00:00.000000000 +0100 +++ new/httpx2-2.5.0/tests/httpx2/test_sse.py 2026-06-25 16:14:41.000000000 +0200 @@ -0,0 +1,306 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator, Iterator + +import pytest + +import httpx2 + +SSE_BODY = b": this is a comment\nevent: ping\ndata: hello\nid: 1\nretry: 500\n\ndata: first\ndata: second\n\n" + + +def sse_handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=SSE_BODY, headers={"Content-Type": "text/event-stream"}) + + +def test_sse_sync() -> None: + with httpx2.Client(transport=httpx2.MockTransport(sse_handler)) as client: + with client.sse("http://testserver/sse") as source: + events = list(source) + + assert events == [ + httpx2.ServerSentEvent(event="ping", data="hello", id="1", retry=500), + httpx2.ServerSentEvent(event="message", data="first\nsecond", id="1"), + ] + + [email protected] +async def test_sse_async() -> None: + async with httpx2.AsyncClient(transport=httpx2.MockTransport(sse_handler)) as client: + async with client.sse("http://testserver/sse") as source: + events = [event async for event in source] + + assert events == [ + httpx2.ServerSentEvent(event="ping", data="hello", id="1", retry=500), + httpx2.ServerSentEvent(event="message", data="first\nsecond", id="1"), + ] + + +def test_default_event_is_message() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=b"data: hi\n\n", headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + (event,) = list(source) + + assert event.event == "message" + assert event.data == "hi" + assert event.id == "" + assert event.retry is None + + +def test_json() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=b'data: {"x": 1}\n\n', headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + (event,) = list(source) + + assert event.json() == {"x": 1} + + +def test_invalid_retry_is_ignored() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + body = b"retry: not-a-number\ndata: hi\n\n" + return httpx2.Response(200, content=body, headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + (event,) = list(source) + + assert event.retry is None + + +def test_id_with_null_is_ignored() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + body = b"id: a\0b\ndata: hi\n\n" + return httpx2.Response(200, content=body, headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + (event,) = list(source) + + assert event.id == "" + + +def test_field_without_value() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=b"data\n\n", headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + (event,) = list(source) + + assert event.data == "" + + +def test_last_event_id_persists() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + body = b"id: 1\ndata: a\n\ndata: b\n\n" + return httpx2.Response(200, content=body, headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + events = list(source) + + assert [event.id for event in events] == ["1", "1"] + + +def test_blank_lines_after_id_do_not_dispatch() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + body = b"id: 1\ndata: a\n\n\n\ndata: b\n\n" + return httpx2.Response(200, content=body, headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + events = list(source) + + assert [event.data for event in events] == ["a", "b"] + + +def test_id_only_block_is_dispatched() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=b"id: 1\n\n", headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + (event,) = list(source) + + assert event.id == "1" + assert event.data == "" + + [email protected]( + "content_type", + ["text/event-stream", "text/event-stream; charset=utf-8", "Text/Event-Stream", "TEXT/EVENT-STREAM"], +) +def test_content_type_accepted(content_type: str) -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=b"data: hi\n\n", headers={"Content-Type": content_type}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + (event,) = list(source) + + assert event.data == "hi" + + +def test_content_type_mismatch_raises() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=b"data: hi\n\n", headers={"Content-Type": "application/json"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + with pytest.raises(httpx2.SSEError, match="text/event-stream"): + list(source) + + [email protected] +async def test_content_type_mismatch_raises_async() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=b"data: hi\n\n", headers={"Content-Type": "application/json"}) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: + async with client.sse("http://testserver/sse") as source: + with pytest.raises(httpx2.SSEError, match="text/event-stream"): + [event async for event in source] + + +def test_sets_sse_headers() -> None: + captured: dict[str, str] = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + captured.update(request.headers) + return httpx2.Response(200, content=b"data: hi\n\n", headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + list(source) + + assert captured["accept"] == "text/event-stream" + assert captured["cache-control"] == "no-store" + + +def test_user_headers_take_precedence() -> None: + captured: dict[str, str] = {} + + def handler(request: httpx2.Request) -> httpx2.Response: + captured.update(request.headers) + return httpx2.Response(200, content=b"data: hi\n\n", headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse", headers={"Accept": "text/event-stream, application/json"}) as source: + list(source) + + assert captured["accept"] == "text/event-stream, application/json" + + +def test_response_is_accessible() -> None: + with httpx2.Client(transport=httpx2.MockTransport(sse_handler)) as client: + with client.sse("http://testserver/sse") as source: + assert isinstance(source.response, httpx2.Response) + assert source.response.status_code == 200 + + +def test_post_method() -> None: + captured: list[str] = [] + + def handler(request: httpx2.Request) -> httpx2.Response: + captured.append(request.method) + return httpx2.Response(200, content=b"data: hi\n\n", headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse", method="POST", json={"q": 1}) as source: + list(source) + + assert captured == ["POST"] + + +def test_chunk_boundaries_and_crlf_sync() -> None: + def chunks() -> Iterator[bytes]: + yield b"data: he" + yield b"llo\r\n\r" + yield b"\ndata: wor" + yield b"ld\r\n\r\n" + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=chunks(), headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + events = list(source) + + assert [event.data for event in events] == ["hello", "world"] + + [email protected] +async def test_chunk_boundaries_and_crlf_async() -> None: + async def chunks() -> AsyncIterator[bytes]: + yield b"data: he" + yield b"llo\r\n\r" + yield b"\ndata: wor" + yield b"ld\r\n\r\n" + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=chunks(), headers={"Content-Type": "text/event-stream"}) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: + async with client.sse("http://testserver/sse") as source: + events = [event async for event in source] + + assert [event.data for event in events] == ["hello", "world"] + + +def test_event_without_trailing_blank_line() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=b"data: hi", headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + events = list(source) + + assert events == [] + + +def test_leading_blank_line_is_ignored() -> None: + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=b"\ndata: hi\n\n", headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + (event,) = list(source) + + assert event.data == "hi" + + +def test_event_dispatched_at_eof_on_trailing_cr_sync() -> None: + def chunks() -> Iterator[bytes]: + yield b"data: hi\n" + yield b"\r" + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=chunks(), headers={"Content-Type": "text/event-stream"}) + + with httpx2.Client(transport=httpx2.MockTransport(handler)) as client: + with client.sse("http://testserver/sse") as source: + (event,) = list(source) + + assert event.data == "hi" + + [email protected] +async def test_event_dispatched_at_eof_on_trailing_cr_async() -> None: + async def chunks() -> AsyncIterator[bytes]: + yield b"data: hi\n" + yield b"\r" + + def handler(request: httpx2.Request) -> httpx2.Response: + return httpx2.Response(200, content=chunks(), headers={"Content-Type": "text/event-stream"}) + + async with httpx2.AsyncClient(transport=httpx2.MockTransport(handler)) as client: + async with client.sse("http://testserver/sse") as source: + events = [event async for event in source] + + assert [event.data for event in events] == ["hi"] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpx2-2.4.0/tests/httpx2/test_utils.py new/httpx2-2.5.0/tests/httpx2/test_utils.py --- old/httpx2-2.4.0/tests/httpx2/test_utils.py 2026-06-11 08:29:52.000000000 +0200 +++ new/httpx2-2.5.0/tests/httpx2/test_utils.py 2026-06-25 16:14:41.000000000 +0200 @@ -106,6 +106,7 @@ ({"no_proxy": "127.0.0.1"}, {"all://127.0.0.1": None}), ({"no_proxy": "192.168.0.0/16"}, {"all://192.168.0.0/16": None}), ({"no_proxy": "::1"}, {"all://[::1]": None}), + ({"no_proxy": "fe11::/16"}, {"all://[fe11::]/16": None}), ({"no_proxy": "localhost"}, {"all://localhost": None}), ({"no_proxy": "github.com"}, {"all://*github.com": None}), ({"no_proxy": ".github.com"}, {"all://*.github.com": None}),
