Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-httpcore for openSUSE:Factory checked in at 2023-10-26 17:11:47 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-httpcore (Old) and /work/SRC/openSUSE:Factory/.python-httpcore.new.24901 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-httpcore" Thu Oct 26 17:11:47 2023 rev:12 rq:1120297 version:0.18.0 Changes: -------- --- /work/SRC/openSUSE:Factory/python-httpcore/python-httpcore.changes 2023-09-12 21:02:33.671486062 +0200 +++ /work/SRC/openSUSE:Factory/.python-httpcore.new.24901/python-httpcore.changes 2023-10-26 17:11:50.078574000 +0200 @@ -1,0 +2,14 @@ +Wed Oct 25 11:30:12 UTC 2023 - Matej Cepl <mc...@cepl.eu> + +- Update to 0.18.0: + - Add support for HTTPS proxies. + - Handle sni_hostname extension with SOCKS proxy. + - Change the type of Extensions from Mapping[Str, Any] to + MutableMapping[Str, Any]. + - Handle HTTP/1.1 half-closed connections gracefully. + - Drop Python 3.7 support. +- Update httpcore-allow-deprecationwarnings-test.patch +- Skip failing tests test_ssl_request and test_extra_info + (gh#encode/httpcore!832) + +------------------------------------------------------------------- Old: ---- httpcore-0.17.3.tar.gz New: ---- httpcore-0.18.0.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-httpcore.spec ++++++ --- /var/tmp/diff_new_pack.JP9gi1/_old 2023-10-26 17:11:52.234653186 +0200 +++ /var/tmp/diff_new_pack.JP9gi1/_new 2023-10-26 17:11:52.234653186 +0200 @@ -27,7 +27,7 @@ %{?sle15_python_module_pythons} Name: python-httpcore%{psuffix} -Version: 0.17.3 +Version: 0.18.0 Release: 0 Summary: Minimal low-level Python HTTP client License: BSD-3-Clause @@ -36,7 +36,10 @@ # PATCH-FIX-UPSTREAM httpcore-allow-deprecationwarnings-test.patch gh#encode/httpcore#511, gh#agronholm/anyio#470 Patch1: httpcore-allow-deprecationwarnings-test.patch BuildRequires: %{python_module base >= 3.7} -BuildRequires: %{python_module setuptools} +BuildRequires: %{python_module hatch-fancy-pypi-readme} +BuildRequires: %{python_module hatchling} +BuildRequires: %{python_module pip} +BuildRequires: %{python_module wheel} BuildRequires: fdupes BuildRequires: python-rpm-macros Requires: python-certifi @@ -68,10 +71,10 @@ %if !%{with test} %build -%python_build +%pyproject_wheel %install -%python_install +%pyproject_install %python_expand %fdupes %{buildroot}%{$python_sitelib} %endif @@ -81,6 +84,8 @@ donttest="socks5" # gh#encode/httpcore#622 donttest+=" or test_request_with_content" +# gh#encode/httpcore!832 +donttest+=" or test_ssl_request or test_extra_info" %pytest -rsfE --asyncio-mode=strict -p no:unraisableexception -k "not ($donttest)" %endif ++++++ httpcore-0.17.3.tar.gz -> httpcore-0.18.0.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/.github/ISSUE_TEMPLATE/1-issue.md new/httpcore-0.18.0/.github/ISSUE_TEMPLATE/1-issue.md --- old/httpcore-0.17.3/.github/ISSUE_TEMPLATE/1-issue.md 1970-01-01 01:00:00.000000000 +0100 +++ new/httpcore-0.18.0/.github/ISSUE_TEMPLATE/1-issue.md 2023-09-08 15:37:47.000000000 +0200 @@ -0,0 +1,16 @@ +--- +name: Issue +about: Please only raise an issue if you've been advised to do so after discussion. Thanks! ð +--- + +The starting point for issues should usually be a discussion... + +https://github.com/encode/httpcore/discussions + +Possible bugs may be raised as a "Potential Issue" discussion, feature requests may be raised as an "Ideas" discussion. We can then determine if the discussion needs to be escalated into an "Issue" or not. + +This will help us ensure that the "Issues" list properly reflects ongoing or needed work on the project. + +--- + +- [ ] Initially raised as discussion #... diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/.github/ISSUE_TEMPLATE/config.yml new/httpcore-0.18.0/.github/ISSUE_TEMPLATE/config.yml --- old/httpcore-0.17.3/.github/ISSUE_TEMPLATE/config.yml 1970-01-01 01:00:00.000000000 +0100 +++ new/httpcore-0.18.0/.github/ISSUE_TEMPLATE/config.yml 2023-09-08 15:37:47.000000000 +0200 @@ -0,0 +1,11 @@ +# Ref: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser +blank_issues_enabled: false +contact_links: +- name: Discussions + url: https://github.com/encode/httpcore/discussions + about: > + The "Discussions" forum is where you want to start. ð +- name: Chat + url: https://gitter.im/encode/community + about: > + Our community chat forum. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/.github/PULL_REQUEST_TEMPLATE.md new/httpcore-0.18.0/.github/PULL_REQUEST_TEMPLATE.md --- old/httpcore-0.17.3/.github/PULL_REQUEST_TEMPLATE.md 1970-01-01 01:00:00.000000000 +0100 +++ new/httpcore-0.18.0/.github/PULL_REQUEST_TEMPLATE.md 2023-09-08 15:37:47.000000000 +0200 @@ -0,0 +1,12 @@ +<!-- Thanks for contributing to HTTP Core! ð +Given this is a project maintained by volunteers, please read this template to not waste your time, or ours! ð --> + +# Summary + +<!-- Write a small summary about what is happening here. --> + +# Checklist + +- [ ] I understand that this PR may be closed in case there was no previous discussion. (This doesn't apply to typos!) +- [ ] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change. +- [ ] I've updated the documentation accordingly. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/.github/workflows/publish.yml new/httpcore-0.18.0/.github/workflows/publish.yml --- old/httpcore-0.17.3/.github/workflows/publish.yml 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/.github/workflows/publish.yml 2023-09-08 15:37:47.000000000 +0200 @@ -17,7 +17,7 @@ - uses: "actions/checkout@v3" - uses: "actions/setup-python@v4" with: - python-version: 3.7 + python-version: 3.8 - name: "Install dependencies" run: "scripts/install" - name: "Build package & docs" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/.github/workflows/test-suite.yml new/httpcore-0.18.0/.github/workflows/test-suite.yml --- old/httpcore-0.17.3/.github/workflows/test-suite.yml 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/.github/workflows/test-suite.yml 2023-09-08 15:37:47.000000000 +0200 @@ -14,7 +14,7 @@ strategy: matrix: - python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] + python-version: ["3.8", "3.9", "3.10", "3.11"] steps: - uses: "actions/checkout@v3" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/CHANGELOG.md new/httpcore-0.18.0/CHANGELOG.md --- old/httpcore-0.17.3/CHANGELOG.md 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/CHANGELOG.md 2023-09-08 15:37:47.000000000 +0200 @@ -4,12 +4,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). -## 0.17.3 (5th July 2023) +## 0.18.0 (September 8th, 2023) + +- Add support for HTTPS proxies. (#745, #786) +- Drop Python 3.7 support. (#727) +- Handle `sni_hostname` extension with SOCKS proxy. (#774) +- Handle HTTP/1.1 half-closed connections gracefully. (#641) +- Change the type of `Extensions` from `Mapping[Str, Any]` to `MutableMapping[Str, Any]`. (#762) + +## 0.17.3 (July 5th, 2023) - Support async cancellations, ensuring that the connection pool is left in a clean state when cancellations occur. (#726) - The networking backend interface has [been added to the public API](https://www.encode.io/httpcore/network-backends). Some classes which were previously private implementation detail are now part of the top-level public API. (#699) - Graceful handling of HTTP/2 GoAway frames, with requests being transparently retried on a new connection. (#730) - Add exceptions when a synchronous `trace callback` is passed to an asynchronous request or an asynchronous `trace callback` is passed to a synchronous request. (#717) +- Drop Python 3.7 support. (#727) ## 0.17.2 (May 23th, 2023) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/MANIFEST.in new/httpcore-0.18.0/MANIFEST.in --- old/httpcore-0.17.3/MANIFEST.in 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/MANIFEST.in 1970-01-01 01:00:00.000000000 +0100 @@ -1,4 +0,0 @@ -include README.md -include CHANGELOG.md -include LICENSE.md -include httpcore/py.typed diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/README.md new/httpcore-0.18.0/README.md --- old/httpcore-0.17.3/README.md 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/README.md 2023-09-08 15:37:47.000000000 +0200 @@ -25,7 +25,7 @@ ## Requirements -Python 3.7+ +Python 3.8+ ## Installation diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/docs/index.md new/httpcore-0.18.0/docs/index.md --- old/httpcore-0.17.3/docs/index.md 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/docs/index.md 2023-09-08 15:37:47.000000000 +0200 @@ -25,7 +25,7 @@ ## Requirements -Python 3.7+ +Python 3.8+ ## Installation diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/docs/proxies.md new/httpcore-0.18.0/docs/proxies.md --- old/httpcore-0.17.3/docs/proxies.md 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/docs/proxies.md 2023-09-08 15:37:47.000000000 +0200 @@ -51,10 +51,33 @@ ) ``` -## Proxy SSL and HTTP Versions +## Proxy SSL -Proxy support currently only allows for HTTP/1.1 connections to the proxy, -and does not currently support SSL proxy connections, which require HTTPS-in-HTTPS, +The `httpcore` package also supports HTTPS proxies for http and https destinations. + +HTTPS proxies can be used in the same way that HTTP proxies are. + +```python +proxy = httpcore.HTTPProxy(proxy_url="https://127.0.0.1:8080/") +``` + +Also, when using HTTPS proxies, you may need to configure the SSL context, which you can do with the `proxy_ssl_context` argument. + +```python +import ssl +import httpcore + +proxy_ssl_context = ssl.create_default_context() +proxy_ssl_context.check_hostname = False + +proxy = httpcore.HTTPProxy('https://127.0.0.1:8080/', proxy_ssl_context=proxy_ssl_context) +``` + +It is important to note that the `ssl_context` argument is always used for the remote connection, and the `proxy_ssl_context` argument is always used for the proxy connection. + +## HTTP Versions + +If you use proxies, keep in mind that the `httpcore` package only supports proxies to HTTP/1.1 servers. ## SOCKS proxy support diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/httpcore/__init__.py new/httpcore-0.18.0/httpcore/__init__.py --- old/httpcore-0.17.3/httpcore/__init__.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/httpcore/__init__.py 2023-09-08 15:37:47.000000000 +0200 @@ -130,7 +130,7 @@ "WriteError", ] -__version__ = "0.17.3" +__version__ = "0.18.0" __locals = locals() diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/httpcore/_async/connection.py new/httpcore-0.18.0/httpcore/_async/connection.py --- old/httpcore-0.17.3/httpcore/_async/connection.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/httpcore/_async/connection.py 2023-09-08 15:37:47.000000000 +0200 @@ -21,9 +21,16 @@ def exponential_backoff(factor: float) -> Iterator[float]: + """ + Generate a geometric sequence that has a ratio of 2 and starts with 0. + + For example: + - `factor = 2`: `0, 2, 4, 8, 16, 32, 64, ...` + - `factor = 3`: `0, 3, 6, 12, 24, 48, 96, ...` + """ yield 0 - for n in itertools.count(2): - yield factor * (2 ** (n - 2)) + for n in itertools.count(): + yield factor * 2**n class AsyncHTTPConnection(AsyncConnectionInterface): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/httpcore/_async/http11.py new/httpcore-0.18.0/httpcore/_async/http11.py --- old/httpcore-0.17.3/httpcore/_async/http11.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/httpcore/_async/http11.py 2023-09-08 15:37:47.000000000 +0200 @@ -20,6 +20,7 @@ ConnectionNotAvailable, LocalProtocolError, RemoteProtocolError, + WriteError, map_exceptions, ) from .._models import Origin, Request, Response @@ -84,10 +85,21 @@ try: kwargs = {"request": request} - async with Trace("send_request_headers", logger, request, kwargs) as trace: - await self._send_request_headers(**kwargs) - async with Trace("send_request_body", logger, request, kwargs) as trace: - await self._send_request_body(**kwargs) + try: + async with Trace( + "send_request_headers", logger, request, kwargs + ) as trace: + await self._send_request_headers(**kwargs) + async with Trace("send_request_body", logger, request, kwargs) as trace: + await self._send_request_body(**kwargs) + except WriteError: + # If we get a write error while we're writing the request, + # then we supress this error and move on to attempting to + # read the response. Servers can sometimes close the request + # pre-emptively and then respond with a well formed HTTP + # error response. + pass + async with Trace( "receive_response_headers", logger, request, kwargs ) as trace: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/httpcore/_async/http_proxy.py new/httpcore-0.18.0/httpcore/_async/http_proxy.py --- old/httpcore-0.17.3/httpcore/_async/http_proxy.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/httpcore/_async/http_proxy.py 2023-09-08 15:37:47.000000000 +0200 @@ -64,6 +64,7 @@ proxy_auth: Optional[Tuple[Union[bytes, str], Union[bytes, str]]] = None, proxy_headers: Union[HeadersAsMapping, HeadersAsSequence, None] = None, ssl_context: Optional[ssl.SSLContext] = None, + proxy_ssl_context: Optional[ssl.SSLContext] = None, max_connections: Optional[int] = 10, max_keepalive_connections: Optional[int] = None, keepalive_expiry: Optional[float] = None, @@ -88,6 +89,7 @@ ssl_context: An SSL context to use for verifying connections. If not specified, the default `httpcore.default_ssl_context()` will be used. + proxy_ssl_context: The same as `ssl_context`, but for a proxy server rather than a remote origin. max_connections: The maximum number of concurrent HTTP connections that the pool should allow. Any attempt to send a request on a pool that would exceed this amount will block until a connection is available. @@ -122,8 +124,17 @@ uds=uds, socket_options=socket_options, ) - self._ssl_context = ssl_context + self._proxy_url = enforce_url(proxy_url, name="proxy_url") + if ( + self._proxy_url.scheme == b"http" and proxy_ssl_context is not None + ): # pragma: no cover + raise RuntimeError( + "The `proxy_ssl_context` argument is not allowed for the http scheme" + ) + + self._ssl_context = ssl_context + self._proxy_ssl_context = proxy_ssl_context self._proxy_headers = enforce_headers(proxy_headers, name="proxy_headers") if proxy_auth is not None: username = enforce_bytes(proxy_auth[0], name="proxy_auth") @@ -141,12 +152,14 @@ remote_origin=origin, keepalive_expiry=self._keepalive_expiry, network_backend=self._network_backend, + proxy_ssl_context=self._proxy_ssl_context, ) return AsyncTunnelHTTPConnection( proxy_origin=self._proxy_url.origin, proxy_headers=self._proxy_headers, remote_origin=origin, ssl_context=self._ssl_context, + proxy_ssl_context=self._proxy_ssl_context, keepalive_expiry=self._keepalive_expiry, http1=self._http1, http2=self._http2, @@ -163,12 +176,14 @@ keepalive_expiry: Optional[float] = None, network_backend: Optional[AsyncNetworkBackend] = None, socket_options: Optional[Iterable[SOCKET_OPTION]] = None, + proxy_ssl_context: Optional[ssl.SSLContext] = None, ) -> None: self._connection = AsyncHTTPConnection( origin=proxy_origin, keepalive_expiry=keepalive_expiry, network_backend=network_backend, socket_options=socket_options, + ssl_context=proxy_ssl_context, ) self._proxy_origin = proxy_origin self._proxy_headers = enforce_headers(proxy_headers, name="proxy_headers") @@ -222,6 +237,7 @@ proxy_origin: Origin, remote_origin: Origin, ssl_context: Optional[ssl.SSLContext] = None, + proxy_ssl_context: Optional[ssl.SSLContext] = None, proxy_headers: Optional[Sequence[Tuple[bytes, bytes]]] = None, keepalive_expiry: Optional[float] = None, http1: bool = True, @@ -234,10 +250,12 @@ keepalive_expiry=keepalive_expiry, network_backend=network_backend, socket_options=socket_options, + ssl_context=proxy_ssl_context, ) self._proxy_origin = proxy_origin self._remote_origin = remote_origin self._ssl_context = ssl_context + self._proxy_ssl_context = proxy_ssl_context self._proxy_headers = enforce_headers(proxy_headers, name="proxy_headers") self._keepalive_expiry = keepalive_expiry self._http1 = http1 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/httpcore/_async/socks_proxy.py new/httpcore-0.18.0/httpcore/_async/socks_proxy.py --- old/httpcore-0.17.3/httpcore/_async/socks_proxy.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/httpcore/_async/socks_proxy.py 2023-09-08 15:37:47.000000000 +0200 @@ -216,6 +216,7 @@ async def handle_async_request(self, request: Request) -> Response: timeouts = request.extensions.get("timeout", {}) + sni_hostname = request.extensions.get("sni_hostname", None) timeout = timeouts.get("connect", None) async with self._connect_lock: @@ -258,7 +259,8 @@ kwargs = { "ssl_context": ssl_context, - "server_hostname": self._remote_origin.host.decode("ascii"), + "server_hostname": sni_hostname + or self._remote_origin.host.decode("ascii"), "timeout": timeout, } async with Trace("start_tls", logger, request, kwargs) as trace: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/httpcore/_backends/sync.py new/httpcore-0.18.0/httpcore/_backends/sync.py --- old/httpcore-0.17.3/httpcore/_backends/sync.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/httpcore/_backends/sync.py 2023-09-08 15:37:47.000000000 +0200 @@ -2,6 +2,7 @@ import ssl import sys import typing +from functools import partial from .._exceptions import ( ConnectError, @@ -17,6 +18,103 @@ from .base import SOCKET_OPTION, NetworkBackend, NetworkStream +class TLSinTLSStream(NetworkStream): # pragma: no cover + """ + Because the standard `SSLContext.wrap_socket` method does + not work for `SSLSocket` objects, we need this class + to implement TLS stream using an underlying `SSLObject` + instance in order to support TLS on top of TLS. + """ + + # Defined in RFC 8449 + TLS_RECORD_SIZE = 16384 + + def __init__( + self, + sock: socket.socket, + ssl_context: ssl.SSLContext, + server_hostname: typing.Optional[str] = None, + timeout: typing.Optional[float] = None, + ): + self._sock = sock + self._incoming = ssl.MemoryBIO() + self._outgoing = ssl.MemoryBIO() + + self.ssl_obj = ssl_context.wrap_bio( + incoming=self._incoming, + outgoing=self._outgoing, + server_hostname=server_hostname, + ) + + self._sock.settimeout(timeout) + self._perform_io(self.ssl_obj.do_handshake) + + def _perform_io( + self, + func: typing.Callable[..., typing.Any], + ) -> typing.Any: + ret = None + + while True: + errno = None + try: + ret = func() + except (ssl.SSLWantReadError, ssl.SSLWantWriteError) as e: + errno = e.errno + + self._sock.sendall(self._outgoing.read()) + + if errno == ssl.SSL_ERROR_WANT_READ: + buf = self._sock.recv(self.TLS_RECORD_SIZE) + + if buf: + self._incoming.write(buf) + else: + self._incoming.write_eof() + if errno is None: + return ret + + def read(self, max_bytes: int, timeout: typing.Optional[float] = None) -> bytes: + exc_map: ExceptionMapping = {socket.timeout: ReadTimeout, OSError: ReadError} + with map_exceptions(exc_map): + self._sock.settimeout(timeout) + return typing.cast( + bytes, self._perform_io(partial(self.ssl_obj.read, max_bytes)) + ) + + def write(self, buffer: bytes, timeout: typing.Optional[float] = None) -> None: + exc_map: ExceptionMapping = {socket.timeout: WriteTimeout, OSError: WriteError} + with map_exceptions(exc_map): + self._sock.settimeout(timeout) + while buffer: + nsent = self._perform_io(partial(self.ssl_obj.write, buffer)) + buffer = buffer[nsent:] + + def close(self) -> None: + self._sock.close() + + def start_tls( + self, + ssl_context: ssl.SSLContext, + server_hostname: typing.Optional[str] = None, + timeout: typing.Optional[float] = None, + ) -> "NetworkStream": + raise NotImplementedError() + + def get_extra_info(self, info: str) -> typing.Any: + if info == "ssl_object": + return self.ssl_obj + if info == "client_addr": + return self._sock.getsockname() + if info == "server_addr": + return self._sock.getpeername() + if info == "socket": + return self._sock + if info == "is_readable": + return is_socket_readable(self._sock) + return None + + class SyncStream(NetworkStream): def __init__(self, sock: socket.socket) -> None: self._sock = sock @@ -47,16 +145,30 @@ server_hostname: typing.Optional[str] = None, timeout: typing.Optional[float] = None, ) -> NetworkStream: + if isinstance(self._sock, ssl.SSLSocket): # pragma: no cover + raise RuntimeError( + "Attempted to add a TLS layer on top of the existing " + "TLS stream, which is not supported by httpcore package" + ) + exc_map: ExceptionMapping = { socket.timeout: ConnectTimeout, OSError: ConnectError, } with map_exceptions(exc_map): try: - self._sock.settimeout(timeout) - sock = ssl_context.wrap_socket( - self._sock, server_hostname=server_hostname - ) + if isinstance(self._sock, ssl.SSLSocket): # pragma: no cover + # If the underlying socket has already been upgraded + # to the TLS layer (i.e. is an instance of SSLSocket), + # we need some additional smarts to support TLS-in-TLS. + return TLSinTLSStream( + self._sock, ssl_context, server_hostname, timeout + ) + else: + self._sock.settimeout(timeout) + sock = ssl_context.wrap_socket( + self._sock, server_hostname=server_hostname + ) except Exception as exc: # pragma: nocover self.close() raise exc diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/httpcore/_models.py new/httpcore-0.18.0/httpcore/_models.py --- old/httpcore-0.17.3/httpcore/_models.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/httpcore/_models.py 2023-09-08 15:37:47.000000000 +0200 @@ -6,6 +6,7 @@ Iterator, List, Mapping, + MutableMapping, Optional, Sequence, Tuple, @@ -20,7 +21,7 @@ HeadersAsMapping = Mapping[Union[bytes, str], Union[bytes, str]] HeaderTypes = Union[HeadersAsSequence, HeadersAsMapping, None] -Extensions = Mapping[str, Any] +Extensions = MutableMapping[str, Any] def enforce_bytes(value: Union[bytes, str], *, name: str) -> bytes: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/httpcore/_sync/connection.py new/httpcore-0.18.0/httpcore/_sync/connection.py --- old/httpcore-0.17.3/httpcore/_sync/connection.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/httpcore/_sync/connection.py 2023-09-08 15:37:47.000000000 +0200 @@ -21,9 +21,16 @@ def exponential_backoff(factor: float) -> Iterator[float]: + """ + Generate a geometric sequence that has a ratio of 2 and starts with 0. + + For example: + - `factor = 2`: `0, 2, 4, 8, 16, 32, 64, ...` + - `factor = 3`: `0, 3, 6, 12, 24, 48, 96, ...` + """ yield 0 - for n in itertools.count(2): - yield factor * (2 ** (n - 2)) + for n in itertools.count(): + yield factor * 2**n class HTTPConnection(ConnectionInterface): diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/httpcore/_sync/http11.py new/httpcore-0.18.0/httpcore/_sync/http11.py --- old/httpcore-0.17.3/httpcore/_sync/http11.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/httpcore/_sync/http11.py 2023-09-08 15:37:47.000000000 +0200 @@ -20,6 +20,7 @@ ConnectionNotAvailable, LocalProtocolError, RemoteProtocolError, + WriteError, map_exceptions, ) from .._models import Origin, Request, Response @@ -84,10 +85,21 @@ try: kwargs = {"request": request} - with Trace("send_request_headers", logger, request, kwargs) as trace: - self._send_request_headers(**kwargs) - with Trace("send_request_body", logger, request, kwargs) as trace: - self._send_request_body(**kwargs) + try: + with Trace( + "send_request_headers", logger, request, kwargs + ) as trace: + self._send_request_headers(**kwargs) + with Trace("send_request_body", logger, request, kwargs) as trace: + self._send_request_body(**kwargs) + except WriteError: + # If we get a write error while we're writing the request, + # then we supress this error and move on to attempting to + # read the response. Servers can sometimes close the request + # pre-emptively and then respond with a well formed HTTP + # error response. + pass + with Trace( "receive_response_headers", logger, request, kwargs ) as trace: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/httpcore/_sync/http_proxy.py new/httpcore-0.18.0/httpcore/_sync/http_proxy.py --- old/httpcore-0.17.3/httpcore/_sync/http_proxy.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/httpcore/_sync/http_proxy.py 2023-09-08 15:37:47.000000000 +0200 @@ -64,6 +64,7 @@ proxy_auth: Optional[Tuple[Union[bytes, str], Union[bytes, str]]] = None, proxy_headers: Union[HeadersAsMapping, HeadersAsSequence, None] = None, ssl_context: Optional[ssl.SSLContext] = None, + proxy_ssl_context: Optional[ssl.SSLContext] = None, max_connections: Optional[int] = 10, max_keepalive_connections: Optional[int] = None, keepalive_expiry: Optional[float] = None, @@ -88,6 +89,7 @@ ssl_context: An SSL context to use for verifying connections. If not specified, the default `httpcore.default_ssl_context()` will be used. + proxy_ssl_context: The same as `ssl_context`, but for a proxy server rather than a remote origin. max_connections: The maximum number of concurrent HTTP connections that the pool should allow. Any attempt to send a request on a pool that would exceed this amount will block until a connection is available. @@ -122,8 +124,17 @@ uds=uds, socket_options=socket_options, ) - self._ssl_context = ssl_context + self._proxy_url = enforce_url(proxy_url, name="proxy_url") + if ( + self._proxy_url.scheme == b"http" and proxy_ssl_context is not None + ): # pragma: no cover + raise RuntimeError( + "The `proxy_ssl_context` argument is not allowed for the http scheme" + ) + + self._ssl_context = ssl_context + self._proxy_ssl_context = proxy_ssl_context self._proxy_headers = enforce_headers(proxy_headers, name="proxy_headers") if proxy_auth is not None: username = enforce_bytes(proxy_auth[0], name="proxy_auth") @@ -141,12 +152,14 @@ remote_origin=origin, keepalive_expiry=self._keepalive_expiry, network_backend=self._network_backend, + proxy_ssl_context=self._proxy_ssl_context, ) return TunnelHTTPConnection( proxy_origin=self._proxy_url.origin, proxy_headers=self._proxy_headers, remote_origin=origin, ssl_context=self._ssl_context, + proxy_ssl_context=self._proxy_ssl_context, keepalive_expiry=self._keepalive_expiry, http1=self._http1, http2=self._http2, @@ -163,12 +176,14 @@ keepalive_expiry: Optional[float] = None, network_backend: Optional[NetworkBackend] = None, socket_options: Optional[Iterable[SOCKET_OPTION]] = None, + proxy_ssl_context: Optional[ssl.SSLContext] = None, ) -> None: self._connection = HTTPConnection( origin=proxy_origin, keepalive_expiry=keepalive_expiry, network_backend=network_backend, socket_options=socket_options, + ssl_context=proxy_ssl_context, ) self._proxy_origin = proxy_origin self._proxy_headers = enforce_headers(proxy_headers, name="proxy_headers") @@ -222,6 +237,7 @@ proxy_origin: Origin, remote_origin: Origin, ssl_context: Optional[ssl.SSLContext] = None, + proxy_ssl_context: Optional[ssl.SSLContext] = None, proxy_headers: Optional[Sequence[Tuple[bytes, bytes]]] = None, keepalive_expiry: Optional[float] = None, http1: bool = True, @@ -234,10 +250,12 @@ keepalive_expiry=keepalive_expiry, network_backend=network_backend, socket_options=socket_options, + ssl_context=proxy_ssl_context, ) self._proxy_origin = proxy_origin self._remote_origin = remote_origin self._ssl_context = ssl_context + self._proxy_ssl_context = proxy_ssl_context self._proxy_headers = enforce_headers(proxy_headers, name="proxy_headers") self._keepalive_expiry = keepalive_expiry self._http1 = http1 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/httpcore/_sync/socks_proxy.py new/httpcore-0.18.0/httpcore/_sync/socks_proxy.py --- old/httpcore-0.17.3/httpcore/_sync/socks_proxy.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/httpcore/_sync/socks_proxy.py 2023-09-08 15:37:47.000000000 +0200 @@ -216,6 +216,7 @@ def handle_request(self, request: Request) -> Response: timeouts = request.extensions.get("timeout", {}) + sni_hostname = request.extensions.get("sni_hostname", None) timeout = timeouts.get("connect", None) with self._connect_lock: @@ -258,7 +259,8 @@ kwargs = { "ssl_context": ssl_context, - "server_hostname": self._remote_origin.host.decode("ascii"), + "server_hostname": sni_hostname + or self._remote_origin.host.decode("ascii"), "timeout": timeout, } with Trace("start_tls", logger, request, kwargs) as trace: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/pyproject.toml new/httpcore-0.18.0/pyproject.toml --- old/httpcore-0.17.3/pyproject.toml 1970-01-01 01:00:00.000000000 +0100 +++ new/httpcore-0.18.0/pyproject.toml 2023-09-08 15:37:47.000000000 +0200 @@ -0,0 +1,112 @@ +[build-system] +requires = ["hatchling", "hatch-fancy-pypi-readme"] +build-backend = "hatchling.build" + +[project] +name = "httpcore" +dynamic = ["readme", "version"] +description = "A minimal low-level HTTP client." +license = "BSD-3-Clause" +requires-python = ">=3.8" +authors = [ + { name = "Tom Christie", email = "t...@tomchristie.com" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Web Environment", + "Framework :: AsyncIO", + "Framework :: Trio", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "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", + "Topic :: Internet :: WWW/HTTP", +] +dependencies = [ + "anyio>=3.0,<5.0", + "certifi", + "h11>=0.13,<0.15", + "sniffio==1.*", +] + +[project.optional-dependencies] +http2 = [ + "h2>=3,<5", +] +socks = [ + "socksio==1.*", +] + +[project.urls] +Documentation = "https://www.encode.io/httpcore" +Homepage = "https://www.encode.io/httpcore/" +Source = "https://github.com/encode/httpcore" + +[tool.hatch.version] +path = "httpcore/__init__.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/httpcore", + "/CHANGELOG.md", + "/README.md", +] + +[tool.hatch.metadata.hooks.fancy-pypi-readme] +content-type = "text/markdown" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "README.md" + +[[tool.hatch.metadata.hooks.fancy-pypi-readme.fragments]] +path = "CHANGELOG.md" + +[tool.mypy] +strict = true +show_error_codes = true + +[[tool.mypy.overrides]] +module = "tests.*" +disallow_untyped_defs = false +check_untyped_defs = true + +[[tool.mypy.overrides]] +module = "h2.*" +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "hpack.*" +ignore_missing_imports = true + +[tool.pytest.ini_options] +addopts = ["-rxXs", "--strict-config", "--strict-markers"] +markers = ["copied_from(source, changes=None): mark test as copied from somewhere else, along with a description of changes made to accodomate e.g. our test setup"] +filterwarnings = ["error"] + +[tool.coverage.run] +omit = [ + "venv/*", + "httpcore/_sync/*" +] +include = ["httpcore/*", "tests/*"] + +[tool.ruff] +exclude = [ + "httpcore/_sync", + "tests/_sync", +] +line-length = 120 +select = [ + "E", + "F", + "W", + "I" +] + +[tool.ruff.isort] +combine-as-imports = true diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/requirements.txt new/httpcore-0.18.0/requirements.txt --- old/httpcore-0.17.3/requirements.txt 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/requirements.txt 2023-09-08 15:37:47.000000000 +0200 @@ -5,25 +5,22 @@ # Docs mkdocs==1.4.2 -mkdocs-autorefs==0.3.1 +mkdocs-autorefs==0.5.0 mkdocs-material==9.1.15 mkdocs-material-extensions==1.1.1 mkdocstrings[python-legacy]==0.22.0 jinja2==3.1.2 # Packaging +build==0.10.0 twine -wheel # Tests & Linting -anyio==3.6.2 -autoflake==1.7.7 -black==23.3.0 -coverage==7.2.7 -flake8==3.9.2 # See: https://github.com/PyCQA/flake8/pull/1438 -isort==5.11.4 -importlib-metadata==4.13.0 -mypy==1.2.0 +anyio==3.7.1 +black==23.7.0 +coverage[toml]==7.3.0 +ruff==0.0.277 +mypy==1.5.1 trio-typing==0.8.0 types-certifi==2021.10.8.3 pytest==7.4.0 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/scripts/build new/httpcore-0.18.0/scripts/build --- old/httpcore-0.17.3/scripts/build 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/scripts/build 2023-09-08 15:37:47.000000000 +0200 @@ -10,6 +10,6 @@ set -x -${PREFIX}python setup.py sdist bdist_wheel +${PREFIX}python -m build ${PREFIX}twine check dist/* ${PREFIX}mkdocs build diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/scripts/check new/httpcore-0.18.0/scripts/check --- old/httpcore-0.17.3/scripts/check 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/scripts/check 2023-09-08 15:37:47.000000000 +0200 @@ -8,8 +8,7 @@ set -x -${PREFIX}isort --check --diff --project=httpcore $SOURCE_FILES -${PREFIX}black --exclude '/(_sync|sync_tests)/' --check --diff --target-version=py37 $SOURCE_FILES -${PREFIX}flake8 $SOURCE_FILES +${PREFIX}ruff check --show-source $SOURCE_FILES +${PREFIX}black --exclude '/(_sync|sync_tests)/' --check --diff $SOURCE_FILES ${PREFIX}mypy $SOURCE_FILES scripts/unasync --check diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/scripts/lint new/httpcore-0.18.0/scripts/lint --- old/httpcore-0.17.3/scripts/lint 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/scripts/lint 2023-09-08 15:37:47.000000000 +0200 @@ -8,9 +8,8 @@ set -x -${PREFIX}autoflake --in-place --recursive --remove-all-unused-imports $SOURCE_FILES -${PREFIX}isort --project=httpcore $SOURCE_FILES -${PREFIX}black --target-version=py37 --exclude '/(_sync|sync_tests)/' $SOURCE_FILES +${PREFIX}ruff --fix $SOURCE_FILES +${PREFIX}black --exclude '/(_sync|sync_tests)/' $SOURCE_FILES # Run unasync last because its `--check` mode is not aware of code formatters. # (This means sync code isn't prettified, and that's mostly okay.) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/setup.cfg new/httpcore-0.18.0/setup.cfg --- old/httpcore-0.17.3/setup.cfg 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/setup.cfg 1970-01-01 01:00:00.000000000 +0100 @@ -1,36 +0,0 @@ -[flake8] -ignore = W503, E203, B305 -max-line-length = 120 -exclude = httpcore/_sync,tests/_sync - -[mypy] -strict = True -show_error_codes = True - -[mypy-tests.*] -disallow_untyped_defs = False -check_untyped_defs = True - -[mypy-h2.*] -ignore_missing_imports = True - -[mypy-hpack.*] -ignore_missing_imports = True - -[tool:isort] -profile = black -combine_as_imports = True -known_first_party = httpcore,tests -known_third_party = brotli,certifi,chardet,cryptography,h11,h2,hstspreload,pytest,rfc3986,setuptools,sniffio,trio,trustme,urllib3,uvicorn -skip = httpcore/_sync/,tests/_sync - -[tool:pytest] -addopts = -rxXs --strict-config --strict-markers -markers = - copied_from(source, changes=None): mark test as copied from somewhere else, along with a description of changes made to accodomate e.g. our test setup -filterwarnings = - error - -[coverage:run] -omit = venv/*, httpcore/_sync/* -include = httpcore/*, tests/* diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/setup.py new/httpcore-0.18.0/setup.py --- old/httpcore-0.17.3/setup.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/setup.py 1970-01-01 01:00:00.000000000 +0100 @@ -1,83 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import re -from pathlib import Path - -from setuptools import setup - - -def get_version(package): - """ - Return package version as listed in `__version__` in `init.py`. - """ - version = Path(package, "__init__.py").read_text() - return re.search("__version__ = ['\"]([^'\"]+)['\"]", version).group(1) - - -def get_long_description(): - """ - Return the README. - """ - long_description = "" - with open("README.md", encoding="utf8") as f: - long_description += f.read() - long_description += "\n\n" - with open("CHANGELOG.md", encoding="utf8") as f: - long_description += f.read() - return long_description - - -def get_packages(package): - """ - Return root package and all sub-packages. - """ - return [str(path.parent) for path in Path(package).glob("**/__init__.py")] - - -setup( - name="httpcore", - python_requires=">=3.7", - version=get_version("httpcore"), - url="https://github.com/encode/httpcore", - project_urls={ - "Documentation": "https://www.encode.io/httpcore", - "Source": "https://github.com/encode/httpcore", - }, - license="BSD", - description="A minimal low-level HTTP client.", - long_description=get_long_description(), - long_description_content_type="text/markdown", - author="Tom Christie", - author_email="t...@tomchristie.com", - packages=get_packages("httpcore"), - include_package_data=True, - zip_safe=False, - install_requires=[ - "h11>=0.13,<0.15", - "sniffio==1.*", - "anyio>=3.0,<5.0", - "certifi", - ], - extras_require={ - "http2": ["h2>=3,<5"], - "socks": ["socksio==1.*"] - }, - classifiers=[ - "Development Status :: 3 - Alpha", - "Environment :: Web Environment", - "Intended Audience :: Developers", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Topic :: Internet :: WWW/HTTP", - "Framework :: AsyncIO", - "Framework :: Trio", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3 :: Only", - ], -) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/tests/_async/test_connection.py new/httpcore-0.18.0/tests/_async/test_connection.py --- old/httpcore-0.17.3/tests/_async/test_connection.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/tests/_async/test_connection.py 2023-09-08 15:37:47.000000000 +0200 @@ -9,10 +9,13 @@ SOCKET_OPTION, AsyncHTTPConnection, AsyncMockBackend, + AsyncMockStream, AsyncNetworkStream, ConnectError, ConnectionNotAvailable, Origin, + RemoteProtocolError, + WriteError, ) @@ -83,7 +86,109 @@ await conn.request("GET", "https://example.com/") +@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") @pytest.mark.anyio +async def test_write_error_with_response_sent(): + """ + If a server half-closes the connection while the client is sending + the request, it may still send a response. In this case the client + should successfully read and return the response. + + See also the `test_write_error_without_response_sent` test above. + """ + + class ErrorOnRequestTooLargeStream(AsyncMockStream): + def __init__(self, buffer: typing.List[bytes], http2: bool = False) -> None: + super().__init__(buffer, http2) + self.count = 0 + + async def write( + self, buffer: bytes, timeout: typing.Optional[float] = None + ) -> None: + self.count += len(buffer) + + if self.count > 1_000_000: + raise WriteError() + + class ErrorOnRequestTooLarge(AsyncMockBackend): + async def connect_tcp( + self, + host: str, + port: int, + timeout: typing.Optional[float] = None, + local_address: typing.Optional[str] = None, + socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None, + ) -> AsyncMockStream: + return ErrorOnRequestTooLargeStream(list(self._buffer), http2=self._http2) + + origin = Origin(b"https", b"example.com", 443) + network_backend = ErrorOnRequestTooLarge( + [ + b"HTTP/1.1 413 Payload Too Large\r\n", + b"Content-Type: plain/text\r\n", + b"Content-Length: 37\r\n", + b"\r\n", + b"Request body exceeded 1,000,000 bytes", + ] + ) + + async with AsyncHTTPConnection( + origin=origin, network_backend=network_backend, keepalive_expiry=5.0 + ) as conn: + content = b"x" * 10_000_000 + response = await conn.request("POST", "https://example.com/", content=content) + assert response.status == 413 + assert response.content == b"Request body exceeded 1,000,000 bytes" + + +@pytest.mark.anyio +@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") +async def test_write_error_without_response_sent(): + """ + If a server fully closes the connection while the client is sending + the request, then client should raise an error. + + See also the `test_write_error_with_response_sent` test above. + """ + + class ErrorOnRequestTooLargeStream(AsyncMockStream): + def __init__(self, buffer: typing.List[bytes], http2: bool = False) -> None: + super().__init__(buffer, http2) + self.count = 0 + + async def write( + self, buffer: bytes, timeout: typing.Optional[float] = None + ) -> None: + self.count += len(buffer) + + if self.count > 1_000_000: + raise WriteError() + + class ErrorOnRequestTooLarge(AsyncMockBackend): + async def connect_tcp( + self, + host: str, + port: int, + timeout: typing.Optional[float] = None, + local_address: typing.Optional[str] = None, + socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None, + ) -> AsyncMockStream: + return ErrorOnRequestTooLargeStream(list(self._buffer), http2=self._http2) + + origin = Origin(b"https", b"example.com", 443) + network_backend = ErrorOnRequestTooLarge([]) + + async with AsyncHTTPConnection( + origin=origin, network_backend=network_backend, keepalive_expiry=5.0 + ) as conn: + content = b"x" * 10_000_000 + with pytest.raises(RemoteProtocolError) as exc_info: + await conn.request("POST", "https://example.com/", content=content) + assert str(exc_info.value) == "Server disconnected without sending a response." + + +@pytest.mark.anyio +@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") async def test_http2_connection(): origin = Origin(b"https", b"example.com", 443) network_backend = AsyncMockBackend( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/tests/_sync/test_connection.py new/httpcore-0.18.0/tests/_sync/test_connection.py --- old/httpcore-0.17.3/tests/_sync/test_connection.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/tests/_sync/test_connection.py 2023-09-08 15:37:47.000000000 +0200 @@ -9,10 +9,13 @@ SOCKET_OPTION, HTTPConnection, MockBackend, + MockStream, NetworkStream, ConnectError, ConnectionNotAvailable, Origin, + RemoteProtocolError, + WriteError, ) @@ -83,7 +86,109 @@ conn.request("GET", "https://example.com/") +@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") +def test_write_error_with_response_sent(): + """ + If a server half-closes the connection while the client is sending + the request, it may still send a response. In this case the client + should successfully read and return the response. + + See also the `test_write_error_without_response_sent` test above. + """ + + class ErrorOnRequestTooLargeStream(MockStream): + def __init__(self, buffer: typing.List[bytes], http2: bool = False) -> None: + super().__init__(buffer, http2) + self.count = 0 + + def write( + self, buffer: bytes, timeout: typing.Optional[float] = None + ) -> None: + self.count += len(buffer) + + if self.count > 1_000_000: + raise WriteError() + + class ErrorOnRequestTooLarge(MockBackend): + def connect_tcp( + self, + host: str, + port: int, + timeout: typing.Optional[float] = None, + local_address: typing.Optional[str] = None, + socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None, + ) -> MockStream: + return ErrorOnRequestTooLargeStream(list(self._buffer), http2=self._http2) + + origin = Origin(b"https", b"example.com", 443) + network_backend = ErrorOnRequestTooLarge( + [ + b"HTTP/1.1 413 Payload Too Large\r\n", + b"Content-Type: plain/text\r\n", + b"Content-Length: 37\r\n", + b"\r\n", + b"Request body exceeded 1,000,000 bytes", + ] + ) + + with HTTPConnection( + origin=origin, network_backend=network_backend, keepalive_expiry=5.0 + ) as conn: + content = b"x" * 10_000_000 + response = conn.request("POST", "https://example.com/", content=content) + assert response.status == 413 + assert response.content == b"Request body exceeded 1,000,000 bytes" + + + +@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") +def test_write_error_without_response_sent(): + """ + If a server fully closes the connection while the client is sending + the request, then client should raise an error. + + See also the `test_write_error_with_response_sent` test above. + """ + + class ErrorOnRequestTooLargeStream(MockStream): + def __init__(self, buffer: typing.List[bytes], http2: bool = False) -> None: + super().__init__(buffer, http2) + self.count = 0 + + def write( + self, buffer: bytes, timeout: typing.Optional[float] = None + ) -> None: + self.count += len(buffer) + + if self.count > 1_000_000: + raise WriteError() + + class ErrorOnRequestTooLarge(MockBackend): + def connect_tcp( + self, + host: str, + port: int, + timeout: typing.Optional[float] = None, + local_address: typing.Optional[str] = None, + socket_options: typing.Optional[typing.Iterable[SOCKET_OPTION]] = None, + ) -> MockStream: + return ErrorOnRequestTooLargeStream(list(self._buffer), http2=self._http2) + + origin = Origin(b"https", b"example.com", 443) + network_backend = ErrorOnRequestTooLarge([]) + + with HTTPConnection( + origin=origin, network_backend=network_backend, keepalive_expiry=5.0 + ) as conn: + content = b"x" * 10_000_000 + with pytest.raises(RemoteProtocolError) as exc_info: + conn.request("POST", "https://example.com/", content=content) + assert str(exc_info.value) == "Server disconnected without sending a response." + + + +@pytest.mark.filterwarnings("ignore::pytest.PytestUnraisableExceptionWarning") def test_http2_connection(): origin = Origin(b"https", b"example.com", 443) network_backend = MockBackend( diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/httpcore-0.17.3/unasync.py new/httpcore-0.18.0/unasync.py --- old/httpcore-0.17.3/unasync.py 2023-07-04 12:34:39.000000000 +0200 +++ new/httpcore-0.18.0/unasync.py 2023-09-08 15:37:47.000000000 +0200 @@ -2,13 +2,12 @@ import os import re import sys +from pprint import pprint SUBS = [ ('from .._backends.auto import AutoBackend', 'from .._backends.sync import SyncBackend'), ('import trio as concurrency', 'from tests import concurrency'), - ('AsyncByteStream', 'SyncByteStream'), ('AsyncIterator', 'Iterator'), - ('AutoBackend', 'SyncBackend'), ('Async([A-Z][A-Za-z0-9_]*)', r'\2'), ('async def', 'def'), ('async with', 'with'), @@ -16,8 +15,6 @@ ('await ', ''), ('handle_async_request', 'handle_request'), ('aclose', 'close'), - ('aclose_func', 'close_func'), - ('aiterator', 'iterator'), ('aiter_stream', 'iter_stream'), ('aread', 'read'), ('asynccontextmanager', 'contextmanager'), @@ -33,10 +30,14 @@ for regex, repl in SUBS ] +USED_SUBS = set() def unasync_line(line): - for regex, repl in COMPILED_SUBS: + for index, (regex, repl) in enumerate(COMPILED_SUBS): + old_line = line line = re.sub(regex, repl, line) + if old_line != line: + USED_SUBS.add(index) return line @@ -81,6 +82,13 @@ unasync_dir("httpcore/_async", "httpcore/_sync", check_only=check_only) unasync_dir("tests/_async", "tests/_sync", check_only=check_only) + if len(USED_SUBS) != len(SUBS): + unused_subs = [SUBS[i] for i in range(len(SUBS)) if i not in USED_SUBS] + + print("These patterns were not used:") + pprint(unused_subs) + exit(1) + if __name__ == '__main__': main() ++++++ httpcore-allow-deprecationwarnings-test.patch ++++++ --- /var/tmp/diff_new_pack.JP9gi1/_old 2023-10-26 17:11:52.326656565 +0200 +++ /var/tmp/diff_new_pack.JP9gi1/_new 2023-10-26 17:11:52.330656712 +0200 @@ -1,18 +1,22 @@ -Index: httpcore-0.16.3/setup.cfg -=================================================================== ---- httpcore-0.16.3.orig/setup.cfg -+++ httpcore-0.16.3/setup.cfg -@@ -30,6 +30,12 @@ markers = - copied_from(source, changes=None): mark test as copied from somewhere else, along with a description of changes made to accodomate e.g. our test setup - filterwarnings = - error -+ # requires anyio 4 with trio 0.22: https://github.com/agronholm/anyio/issues/470 -+ ignore:trio.MultiError is deprecated -+ # fixed by pytest-httpbin (2.0 not released yet): https://github.com/encode/httpcore/pull/511 -+ ignore:unclosed <(socket\.socket|ssl\.SSLSocket) .*:ResourceWarning -+ ignore:ssl\.wrap_socket\(\) is deprecated, use SSLContext\.wrap_socket\(\):DeprecationWarning -+ ignore:ssl\.PROTOCOL_TLS is deprecated:DeprecationWarning +--- + pyproject.toml | 8 +++++++- + 1 file changed, 7 insertions(+), 1 deletion(-) + +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -86,7 +86,13 @@ ignore_missing_imports = true + [tool.pytest.ini_options] + addopts = ["-rxXs", "--strict-config", "--strict-markers"] + markers = ["copied_from(source, changes=None): mark test as copied from somewhere else, along with a description of changes made to accodomate e.g. our test setup"] +-filterwarnings = ["error"] ++filterwarnings = [ ++ "error", ++ "ignore:trio.MultiError is deprecated", ++ "ignore:unclosed <(socket.socket|ssl.SSLSocket) .*:ResourceWarning", ++ "ignore:ssl.wrap_socket() is deprecated, use SSLContext.wrap_socket():DeprecationWarning", ++ "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning" ++] - [coverage:run] - omit = venv/*, httpcore/_sync/* + [tool.coverage.run] + omit = [