Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-httpcore2 for openSUSE:Factory checked in at 2026-10-27 16:47:07 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-httpcore2 (Old) and /work/SRC/openSUSE:Factory/.python-httpcore2.new.2252680 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-httpcore2" Tue Oct 27 16:47:07 2026 rev:5 rq:1392446 version:2.16.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-httpcore2/python-httpcore2.changes 2026-09-28 10:37:03.526346233 +0200 +++ /work/SRC/openSUSE:Factory/.python-httpcore2.new.2252680/python-httpcore2.changes 2026-10-27 16:47:38.599168830 +0100 @@ -1,0 +2,19 @@ +Sun Oct 25 14:54:04 UTC 2026 - Dirk Müller <[email protected]> + +- update to 2.16.0: + * No changes since `2.15.1`. Version bumped to stay in lockstep + with `httpx2`. + * Configure the default SSL context once on Linux, instead of + on every handshake. With OpenSSL older than 3.4, CPU usage + grew over the lifetime of a reused client. + * Drop support for Python 3.10. + * Keep `CONNECT` and absolute-form proxy targets when the + request sets the `target` extension. + * Stop blocking reads when an HTTP/2 server lowers + `SETTINGS_MAX_CONCURRENT_STREAMS` while requests are in + flight. + * Raise `WriteError` instead of `anyio.EndOfStream` when + writing after the peer closes a TLS connection without + `close_notify`. + +------------------------------------------------------------------- Old: ---- httpcore2-2.13.1.tar.gz New: ---- httpcore2-2.16.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-httpcore2.spec ++++++ --- /var/tmp/diff_new_pack.pnHb3o/_old 2026-10-27 16:47:39.463205065 +0100 +++ /var/tmp/diff_new_pack.pnHb3o/_new 2026-10-27 16:47:39.467205232 +0100 @@ -17,7 +17,7 @@ Name: python-httpcore2 -Version: 2.13.1 +Version: 2.16.0 Release: 0 Summary: A minimal low-level HTTP client License: BSD-3-Clause ++++++ httpcore2-2.13.1.tar.gz -> httpcore2-2.16.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore2-2.13.1/CHANGELOG.md new/httpcore2-2.16.0/CHANGELOG.md --- old/httpcore2-2.13.1/CHANGELOG.md 2020-02-02 01:00:00.000000000 +0100 +++ new/httpcore2-2.16.0/CHANGELOG.md 2020-02-02 01:00:00.000000000 +0100 @@ -4,6 +4,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## 2.16.0 (October 21st, 2026) + +No changes since `2.15.1`. Version bumped to stay in lockstep with `httpx2`. + +## 2.15.1 (October 19th, 2026) + +### Fixed + +* Configure the default SSL context once on Linux, instead of on every handshake. With OpenSSL older than 3.4, CPU usage grew over the lifetime of a reused client. + ([#1253](https://github.com/pydantic/httpx2/pull/1253)) + +## 2.15.0 (October 19th, 2026) + +### Removed + +* Drop support for Python 3.10. ([#1274](https://github.com/pydantic/httpx2/pull/1274)) + +## 2.14.0 (October 18th, 2026) + +### Fixed + +* Keep `CONNECT` and absolute-form proxy targets when the request sets the `target` extension. + ([#1262](https://github.com/pydantic/httpx2/pull/1262)) +* Stop blocking reads when an HTTP/2 server lowers `SETTINGS_MAX_CONCURRENT_STREAMS` while requests are in flight. + ([#1217](https://github.com/pydantic/httpx2/pull/1217)) +* Raise `WriteError` instead of `anyio.EndOfStream` when writing after the peer closes a TLS connection without `close_notify`. + ([#1263](https://github.com/pydantic/httpx2/pull/1263)) + ## 2.13.1 (September 23rd, 2026) ### Fixed diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore2-2.13.1/PKG-INFO new/httpcore2-2.16.0/PKG-INFO --- old/httpcore2-2.13.1/PKG-INFO 2020-02-02 01:00:00.000000000 +0100 +++ new/httpcore2-2.16.0/PKG-INFO 2020-02-02 01:00:00.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.5 Name: httpcore2 -Version: 2.13.1 +Version: 2.16.0 Summary: A minimal low-level HTTP client. Project-URL: Changelog, https://github.com/pydantic/httpx2/blob/main/src/httpcore2/CHANGELOG.md Project-URL: Homepage, https://github.com/pydantic/httpx2 @@ -19,14 +19,13 @@ Classifier: Operating System :: OS Independent Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3 :: Only -Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Programming Language :: Python :: 3.12 Classifier: Programming Language :: Python :: 3.13 Classifier: Programming Language :: Python :: 3.14 Classifier: Programming Language :: Python :: 3.15 Classifier: Topic :: Internet :: WWW/HTTP -Requires-Python: >=3.10 +Requires-Python: >=3.11 Requires-Dist: h11>=0.16 Requires-Dist: truststore>=0.10 Provides-Extra: asyncio @@ -152,6 +151,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## 2.16.0 (October 21st, 2026) + +No changes since `2.15.1`. Version bumped to stay in lockstep with `httpx2`. + +## 2.15.1 (October 19th, 2026) + +### Fixed + +* Configure the default SSL context once on Linux, instead of on every handshake. With OpenSSL older than 3.4, CPU usage grew over the lifetime of a reused client. + ([#1253](https://github.com/pydantic/httpx2/pull/1253)) + +## 2.15.0 (October 19th, 2026) + +### Removed + +* Drop support for Python 3.10. ([#1274](https://github.com/pydantic/httpx2/pull/1274)) + +## 2.14.0 (October 18th, 2026) + +### Fixed + +* Keep `CONNECT` and absolute-form proxy targets when the request sets the `target` extension. + ([#1262](https://github.com/pydantic/httpx2/pull/1262)) +* Stop blocking reads when an HTTP/2 server lowers `SETTINGS_MAX_CONCURRENT_STREAMS` while requests are in flight. + ([#1217](https://github.com/pydantic/httpx2/pull/1217)) +* Raise `WriteError` instead of `anyio.EndOfStream` when writing after the peer closes a TLS connection without `close_notify`. + ([#1263](https://github.com/pydantic/httpx2/pull/1263)) + ## 2.13.1 (September 23rd, 2026) ### Fixed diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore2-2.13.1/httpcore2/_async/http2.py new/httpcore2-2.16.0/httpcore2/_async/http2.py --- old/httpcore2-2.13.1/httpcore2/_async/http2.py 2020-02-02 01:00:00.000000000 +0100 +++ new/httpcore2-2.16.0/httpcore2/_async/http2.py 2020-02-02 01:00:00.000000000 +0100 @@ -105,13 +105,23 @@ # its max_concurrent_streams value self._max_streams = 1 + # Revoked permits still held by active or queued requests. + self._max_streams_deficit = 0 + local_settings_max_streams = self._h2_state.local_settings.max_concurrent_streams self._max_streams_semaphore = AsyncSemaphore(local_settings_max_streams) for _ in range(local_settings_max_streams - self._max_streams): await self._max_streams_semaphore.acquire() - await self._max_streams_semaphore.acquire() + # A queued request may receive a permit just before the limit drops. + while True: + await self._max_streams_semaphore.acquire() + with AsyncShieldCancellation(): + async with self._state_lock: + if not self._max_streams_deficit: + break + self._max_streams_deficit -= 1 try: stream_id = self._h2_state.get_next_available_stream_id() @@ -338,7 +348,6 @@ for event in events: if isinstance(event, h2.events.RemoteSettingsChanged): async with Trace("receive_remote_settings", logger, request) as trace: - await self._receive_remote_settings_change(event) trace.return_value = event elif isinstance( @@ -366,17 +375,25 @@ self._h2_state.local_settings.max_concurrent_streams, ) if new_max_streams and new_max_streams != self._max_streams: + # Never wait for permits while holding the read lock. while new_max_streams > self._max_streams: - await self._max_streams_semaphore.release() + if self._max_streams_deficit > 0: + self._max_streams_deficit -= 1 + else: + await self._max_streams_semaphore.release() self._max_streams += 1 while new_max_streams < self._max_streams: - await self._max_streams_semaphore.acquire() + if not self._max_streams_semaphore.acquire_if_available(): + self._max_streams_deficit += 1 self._max_streams -= 1 async def _response_closed(self, stream_id: int) -> None: async with self._state_lock: if stream_id in self._events: - await self._max_streams_semaphore.release() + if self._max_streams_deficit > 0: + self._max_streams_deficit -= 1 + else: + await self._max_streams_semaphore.release() del self._events[stream_id] if self._connection_terminated and not self._events: await self.aclose() @@ -422,7 +439,13 @@ self._connection_error = True raise exc - events: list[h2.events.Event] = self._h2_state.receive_data(data) + # Apply settings and stream admission limits atomically. + with AsyncShieldCancellation(): + async with self._state_lock: + events: list[h2.events.Event] = self._h2_state.receive_data(data) + for event in events: + if isinstance(event, h2.events.RemoteSettingsChanged): + await self._receive_remote_settings_change(event) return events diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore2-2.13.1/httpcore2/_async/http_proxy.py new/httpcore2-2.16.0/httpcore2/_async/http_proxy.py --- old/httpcore2-2.13.1/httpcore2/_async/http_proxy.py 2020-02-02 01:00:00.000000000 +0100 +++ new/httpcore2-2.16.0/httpcore2/_async/http_proxy.py 2020-02-02 01:00:00.000000000 +0100 @@ -184,14 +184,16 @@ scheme=self._proxy_origin.scheme, host=self._proxy_origin.host, port=self._proxy_origin.port, - target=bytes(request.url), + target=bytes(request.url) if request.url.target.startswith(b"/") else request.url.target, ) proxy_request = Request( method=request.method, url=url, headers=headers, content=request.stream, - extensions={key: value for key, value in request.extensions.items() if key != "sni_hostname"}, + extensions={ + key: value for key, value in request.extensions.items() if key not in ("sni_hostname", "target") + }, ) return await self._connection.handle_async_request(proxy_request) @@ -274,7 +276,9 @@ method=b"CONNECT", url=connect_url, headers=connect_headers, - extensions={key: value for key, value in request.extensions.items() if key != "sni_hostname"}, + extensions={ + key: value for key, value in request.extensions.items() if key not in ("sni_hostname", "target") + }, ) connect_response = await self._connection.handle_async_request(connect_request) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore2-2.13.1/httpcore2/_backends/anyio.py new/httpcore2-2.16.0/httpcore2/_backends/anyio.py --- old/httpcore2-2.13.1/httpcore2/_backends/anyio.py 2020-02-02 01:00:00.000000000 +0100 +++ new/httpcore2-2.16.0/httpcore2/_backends/anyio.py 2020-02-02 01:00:00.000000000 +0100 @@ -46,6 +46,7 @@ TimeoutError: WriteTimeout, anyio.BrokenResourceError: WriteError, anyio.ClosedResourceError: WriteError, + anyio.EndOfStream: WriteError, } with map_exceptions(exc_map): with anyio.fail_after(timeout): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore2-2.13.1/httpcore2/_ssl.py new/httpcore2-2.16.0/httpcore2/_ssl.py --- old/httpcore2-2.13.1/httpcore2/_ssl.py 2020-02-02 01:00:00.000000000 +0100 +++ new/httpcore2-2.16.0/httpcore2/_ssl.py 2020-02-02 01:00:00.000000000 +0100 @@ -1,12 +1,40 @@ import os +import platform +import re import ssl import truststore +# Mirrors `truststore`'s Linux fallbacks for when OpenSSL's compiled-in paths are unusable: +# https://github.com/sethmlarson/truststore/blob/0714f72a739d182cdb8502f4e1bb4cf7ebfe4eb5/src/truststore/_openssl.py#L8-L19 +CA_FILE_CANDIDATES = [ + "/etc/ssl/cert.pem", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/pki/tls/cert.pem", + "/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/ca-bundle.pem", +] -def default_ssl_context() -> ssl.SSLContext: - if cafile := os.environ.get("SSL_CERT_FILE"): # pragma: no cover + +# `_trust_env` is internal to `httpx2`; drop it with the Linux branch below once sethmlarson/truststore#213 ships. +def default_ssl_context(*, _trust_env: bool = True) -> ssl.SSLContext: + if _trust_env and (cafile := os.environ.get("SSL_CERT_FILE")): # pragma: no cover return ssl.create_default_context(cafile=cafile) - if capath := os.environ.get("SSL_CERT_DIR"): # pragma: no cover + if _trust_env and (capath := os.environ.get("SSL_CERT_DIR")): # pragma: no cover return ssl.create_default_context(capath=capath) - return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + if platform.system() in ("Windows", "Darwin"): # pragma: no cover + return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + # `truststore` re-adds verify paths per handshake, which OpenSSL < 3.4 accumulates (sethmlarson/truststore#212). + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + # Compiled-in paths, since `set_default_verify_paths()` would read `SSL_CERT_*`. + paths = ssl.get_default_verify_paths() + cafile = paths.openssl_cafile if os.path.isfile(paths.openssl_cafile) else None + capath = paths.openssl_capath if os.path.isdir(paths.openssl_capath) else None + if capath and not cafile: # pragma: no cover + if not any(re.fullmatch(r"[0-9a-fA-F]{8}\.\d", f) for f in os.listdir(capath)): + capath = None + if not cafile and not capath: # pragma: no cover + cafile = next(filter(os.path.isfile, CA_FILE_CANDIDATES), None) + if cafile or capath: + ctx.load_verify_locations(cafile, capath) + return ctx diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore2-2.13.1/httpcore2/_sync/http2.py new/httpcore2-2.16.0/httpcore2/_sync/http2.py --- old/httpcore2-2.13.1/httpcore2/_sync/http2.py 2020-02-02 01:00:00.000000000 +0100 +++ new/httpcore2-2.16.0/httpcore2/_sync/http2.py 2020-02-02 01:00:00.000000000 +0100 @@ -105,13 +105,23 @@ # its max_concurrent_streams value self._max_streams = 1 + # Revoked permits still held by active or queued requests. + self._max_streams_deficit = 0 + local_settings_max_streams = self._h2_state.local_settings.max_concurrent_streams self._max_streams_semaphore = Semaphore(local_settings_max_streams) for _ in range(local_settings_max_streams - self._max_streams): self._max_streams_semaphore.acquire() - self._max_streams_semaphore.acquire() + # A queued request may receive a permit just before the limit drops. + while True: + self._max_streams_semaphore.acquire() + with ShieldCancellation(): + with self._state_lock: + if not self._max_streams_deficit: + break + self._max_streams_deficit -= 1 try: stream_id = self._h2_state.get_next_available_stream_id() @@ -338,7 +348,6 @@ for event in events: if isinstance(event, h2.events.RemoteSettingsChanged): with Trace("receive_remote_settings", logger, request) as trace: - self._receive_remote_settings_change(event) trace.return_value = event elif isinstance( @@ -366,17 +375,25 @@ self._h2_state.local_settings.max_concurrent_streams, ) if new_max_streams and new_max_streams != self._max_streams: + # Never wait for permits while holding the read lock. while new_max_streams > self._max_streams: - self._max_streams_semaphore.release() + if self._max_streams_deficit > 0: + self._max_streams_deficit -= 1 + else: + self._max_streams_semaphore.release() self._max_streams += 1 while new_max_streams < self._max_streams: - self._max_streams_semaphore.acquire() + if not self._max_streams_semaphore.acquire_if_available(): + self._max_streams_deficit += 1 self._max_streams -= 1 def _response_closed(self, stream_id: int) -> None: with self._state_lock: if stream_id in self._events: - self._max_streams_semaphore.release() + if self._max_streams_deficit > 0: + self._max_streams_deficit -= 1 + else: + self._max_streams_semaphore.release() del self._events[stream_id] if self._connection_terminated and not self._events: self.close() @@ -422,7 +439,13 @@ self._connection_error = True raise exc - events: list[h2.events.Event] = self._h2_state.receive_data(data) + # Apply settings and stream admission limits atomically. + with ShieldCancellation(): + with self._state_lock: + events: list[h2.events.Event] = self._h2_state.receive_data(data) + for event in events: + if isinstance(event, h2.events.RemoteSettingsChanged): + self._receive_remote_settings_change(event) return events diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore2-2.13.1/httpcore2/_sync/http_proxy.py new/httpcore2-2.16.0/httpcore2/_sync/http_proxy.py --- old/httpcore2-2.13.1/httpcore2/_sync/http_proxy.py 2020-02-02 01:00:00.000000000 +0100 +++ new/httpcore2-2.16.0/httpcore2/_sync/http_proxy.py 2020-02-02 01:00:00.000000000 +0100 @@ -184,14 +184,16 @@ scheme=self._proxy_origin.scheme, host=self._proxy_origin.host, port=self._proxy_origin.port, - target=bytes(request.url), + target=bytes(request.url) if request.url.target.startswith(b"/") else request.url.target, ) proxy_request = Request( method=request.method, url=url, headers=headers, content=request.stream, - extensions={key: value for key, value in request.extensions.items() if key != "sni_hostname"}, + extensions={ + key: value for key, value in request.extensions.items() if key not in ("sni_hostname", "target") + }, ) return self._connection.handle_request(proxy_request) @@ -274,7 +276,9 @@ method=b"CONNECT", url=connect_url, headers=connect_headers, - extensions={key: value for key, value in request.extensions.items() if key != "sni_hostname"}, + extensions={ + key: value for key, value in request.extensions.items() if key not in ("sni_hostname", "target") + }, ) connect_response = self._connection.handle_request(connect_request) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore2-2.13.1/httpcore2/_synchronization.py new/httpcore2-2.16.0/httpcore2/_synchronization.py --- old/httpcore2-2.13.1/httpcore2/_synchronization.py 2020-02-02 01:00:00.000000000 +0100 +++ new/httpcore2-2.16.0/httpcore2/_synchronization.py 2020-02-02 01:00:00.000000000 +0100 @@ -175,6 +175,28 @@ elif self._backend == "asyncio": await self._anyio_semaphore.acquire() + def acquire_if_available(self) -> bool: + """ + Acquire a permit if one is immediately available, without blocking. + Returns True if a permit was acquired. + + Like `release()`, this requires `setup()` to have run, which any + prior `acquire()` call ensures. + """ + if self._backend == "trio": + try: + self._trio_semaphore.acquire_nowait() + except trio.WouldBlock: + return False + return True + elif self._backend == "asyncio": + try: + self._anyio_semaphore.acquire_nowait() + except anyio.WouldBlock: + return False + return True + return False # pragma: no cover + async def release(self) -> None: if self._backend == "trio": self._trio_semaphore.release() @@ -293,6 +315,9 @@ def acquire(self) -> None: self._semaphore.acquire() + def acquire_if_available(self) -> bool: + return self._semaphore.acquire(blocking=False) + def release(self) -> None: self._semaphore.release() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore2-2.13.1/pyproject.toml new/httpcore2-2.16.0/pyproject.toml --- old/httpcore2-2.13.1/pyproject.toml 2020-02-02 01:00:00.000000000 +0100 +++ new/httpcore2-2.16.0/pyproject.toml 2020-02-02 01:00:00.000000000 +0100 @@ -20,7 +20,7 @@ dynamic = ["readme", "version"] description = "A minimal low-level HTTP client." license = "BSD-3-Clause" -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [{ name = "Tom Christie", email = "[email protected]" }] maintainers = [ { name = "Pydantic Services Inc.", email = "[email protected]" }, @@ -36,7 +36,6 @@ "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13",
