Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: encode/httpcore
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: 0.17.3
Choose a base ref
...
head repository: encode/httpcore
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: 0.18.0
Choose a head ref
  • 20 commits
  • 31 files changed
  • 9 contributors

Commits on Jul 6, 2023

  1. Verified

    This commit was signed with the committer’s verified signature.
    antfu Anthony Fu
    Copy the full SHA
    80ff02f View commit details

Commits on Jul 7, 2023

  1. Handle HTTP/1.1 half-closed connections gracefully. (#641)

    * Handle cases like HTTP 413 where the request writes fail, but a response is still sent correctly.
    
    * Fix 'test_write_error_but_response_sent' test case
    
    * Linting
    
    * Fix imports
    
    * Fix imports
    
    * Fix imports
    
    * Filter out 'PytestUnraisableExceptionWarning'
    
    * Add tests for when the server closes the connection during request writing and does not send a response
    
    * Linting
    tomchristie authored Jul 7, 2023

    Verified

    This commit was created on GitHub.com and signed with GitHub’s verified signature.
    Copy the full SHA
    04842b0 View commit details
  2. Update CHANGELOG.md (#749)

    tomchristie authored Jul 7, 2023

    Verified

    This commit was signed with the committer’s verified signature.
    antfu Anthony Fu
    Copy the full SHA
    7b98091 View commit details

Commits on Jul 11, 2023

  1. Use pyproject.toml for packaging instead of setup.py (#752)

    * Drop setup.py and MANIFEST.in
    
    * Add pyproject.toml file
    
    * Fix license
    
    * Add readme into the dynamic list
    
    * Remove static readme
    
    * Update pyproject.toml
    
    ---------
    
    Co-authored-by: Tom Christie <tom@tomchristie.com>
    karpetrosyan and tomchristie authored Jul 11, 2023

    Verified

    This commit was signed with the committer’s verified signature.
    antfu Anthony Fu
    Copy the full SHA
    e32b2ad View commit details

Commits on Jul 13, 2023

  1. Move configurations to pyproject.toml, use ruff (#754)

    * Move configurations to pyproject.toml, use ruff instead of flake8, autoflake and isort
    
    * Add --fix for ruff
    
    * Add ruff to requirements
    
    * Add black
    
    * Fix omit and include
    
    * Add isort
    
    * Add show-source option to ruff check
    
    * Drop isort configs
    
    Co-authored-by: Zanie <contact@zanie.dev>
    
    * Remove autoflake from scripts/lint
    
    ---------
    
    Co-authored-by: Zanie <contact@zanie.dev>
    karpetrosyan and zanieb authored Jul 13, 2023

    Verified

    This commit was signed with the committer’s verified signature.
    antfu Anthony Fu
    Copy the full SHA
    0211d10 View commit details

Commits on Jul 28, 2023

  1. Change type of Extensions (#762)

    * Change type of Extensions, update changelog
    
    * Add pr number
    
    Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
    
    ---------
    
    Co-authored-by: Marcelo Trylesinski <marcelotryle@gmail.com>
    karpetrosyan and Kludex authored Jul 28, 2023

    Verified

    This commit was signed with the committer’s verified signature.
    antfu Anthony Fu
    Copy the full SHA
    c86d507 View commit details

Commits on Aug 1, 2023

  1. Bump anyio from 3.6.2 to 3.7.1 (#764)

    Bumps [anyio](https://github.com/agronholm/anyio) from 3.6.2 to 3.7.1.
    - [Changelog](https://github.com/agronholm/anyio/blob/master/docs/versionhistory.rst)
    - [Commits](agronholm/anyio@3.6.2...3.7.1)
    
    ---
    updated-dependencies:
    - dependency-name: anyio
      dependency-type: direct:production
      update-type: version-update:semver-minor
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored Aug 1, 2023
    Copy the full SHA
    9e4ffca View commit details
  2. fix changelog (#769)

    karpetrosyan authored Aug 1, 2023
    Copy the full SHA
    21e47a7 View commit details
  3. Bump mypy from 1.2.0 to 1.4.1 (#766)

    Bumps [mypy](https://github.com/python/mypy) from 1.2.0 to 1.4.1.
    - [Commits](python/mypy@v1.2.0...v1.4.1)
    
    ---
    updated-dependencies:
    - dependency-name: mypy
      dependency-type: direct:production
      update-type: version-update:semver-minor
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    Co-authored-by: Tom Christie <tom@tomchristie.com>
    dependabot[bot] and tomchristie authored Aug 1, 2023
    Copy the full SHA
    29f8e90 View commit details

Commits on Aug 3, 2023

  1. refactor: simplify exponential_backoff (#758)

    * refactor: simplify `exponential_backoff`
    
    * Add docstrings
    
    ---------
    
    Co-authored-by: Tom Christie <tom@tomchristie.com>
    m9810223 and tomchristie authored Aug 3, 2023
    Copy the full SHA
    3cca055 View commit details

Commits on Aug 9, 2023

  1. Drop Python 3.7 support (#727)

    * Drop Python 3.7 support
    
    * Changelog
    
    * Changelog
    
    * Fix rebase
    
    * Remove unnecessary black argument, it will be inferred
    
    https://black.readthedocs.io/en/stable/change_log.html#id24
    Pliner authored Aug 9, 2023
    Copy the full SHA
    8b58a9c View commit details

Commits on Aug 13, 2023

  1. Remove useless patterns from unasync.py (#779)

    * Fail tests if there is unused sub in unasync
    
    * Remove patterns
    
    * Simplify unasync
    karpetrosyan authored Aug 13, 2023
    Copy the full SHA
    b649bb0 View commit details

Commits on Sep 1, 2023

  1. Add support for HTTPS proxies (available to trio/asyncio) (#745)

    * Add proxy_ssl_context argument
    
    * Add changelog
    
    * Document HTTPS proxies
    
    * Raise exception when proxy_ssl_context used with the http scheme
    
    * Update docs/proxies.md
    
    Co-authored-by: Tom Christie <tom@tomchristie.com>
    
    * Raise exception when TLS over TLS is used for sync stream
    
    * Update CHANGELOG.md
    
    * Update CHANGELOG.md
    
    ---------
    
    Co-authored-by: Tom Christie <tom@tomchristie.com>
    karpetrosyan and tomchristie authored Sep 1, 2023
    Copy the full SHA
    f30da8c View commit details
  2. Bump black from 23.3.0 to 23.7.0 (#789)

    Bumps [black](https://github.com/psf/black) from 23.3.0 to 23.7.0.
    - [Release notes](https://github.com/psf/black/releases)
    - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
    - [Commits](psf/black@23.3.0...23.7.0)
    
    ---
    updated-dependencies:
    - dependency-name: black
      dependency-type: direct:production
      update-type: version-update:semver-minor
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored Sep 1, 2023
    Copy the full SHA
    81c9c18 View commit details
  3. Bump mkdocs-autorefs from 0.3.1 to 0.5.0 (#790)

    Bumps [mkdocs-autorefs](https://github.com/mkdocstrings/autorefs) from 0.3.1 to 0.5.0.
    - [Release notes](https://github.com/mkdocstrings/autorefs/releases)
    - [Changelog](https://github.com/mkdocstrings/autorefs/blob/main/CHANGELOG.md)
    - [Commits](mkdocstrings/autorefs@0.3.1...0.5.0)
    
    ---
    updated-dependencies:
    - dependency-name: mkdocs-autorefs
      dependency-type: direct:production
      update-type: version-update:semver-minor
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    dependabot[bot] authored Sep 1, 2023
    Copy the full SHA
    b0da109 View commit details
  4. Bump coverage[toml] from 7.2.7 to 7.3.0 (#788)

    Bumps [coverage[toml]](https://github.com/nedbat/coveragepy) from 7.2.7 to 7.3.0.
    - [Release notes](https://github.com/nedbat/coveragepy/releases)
    - [Changelog](https://github.com/nedbat/coveragepy/blob/master/CHANGES.rst)
    - [Commits](nedbat/coveragepy@7.2.7...7.3.0)
    
    ---
    updated-dependencies:
    - dependency-name: coverage[toml]
      dependency-type: direct:production
      update-type: version-update:semver-minor
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    Co-authored-by: Tom Christie <tom@tomchristie.com>
    dependabot[bot] and tomchristie authored Sep 1, 2023
    Copy the full SHA
    310dcfa View commit details
  5. Bump mypy from 1.4.1 to 1.5.1 (#791)

    Bumps [mypy](https://github.com/python/mypy) from 1.4.1 to 1.5.1.
    - [Commits](python/mypy@v1.4.1...v1.5.1)
    
    ---
    updated-dependencies:
    - dependency-name: mypy
      dependency-type: direct:production
      update-type: version-update:semver-minor
    ...
    
    Signed-off-by: dependabot[bot] <support@github.com>
    Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
    Co-authored-by: Tom Christie <tom@tomchristie.com>
    dependabot[bot] and tomchristie authored Sep 1, 2023
    Copy the full SHA
    bb51381 View commit details
  6. Support sni_hostname extension with SOCKS proxy. (#774)

    * Handle `sni_hostname` extension when SOCKS proxy is activated.
    
    * Add tests.
    
    * Reformat the test.
    
    * Run linting checks locally and reformat again.
    
    * Update changelog and add a missing test.
    
    * Update tests/_async/test_socks_proxy.py
    
    * Update tests/_sync/test_socks_proxy.py
    
    ---------
    
    Co-authored-by: Tom Christie <tom@tomchristie.com>
    Allgot and tomchristie authored Sep 1, 2023
    Copy the full SHA
    56c0e4f View commit details
  7. HTTPS proxy support (#786)

    * Add proxy_ssl_context argument
    
    * Add changelog
    
    * Add sync support for TLS-in-TLS connections
    
    * Add HTTPS proxy docs
    
    * Update CHANGELOG.md
    
    * Update httpcore/_sync/http_proxy.py
    
    * Update httpcore/_async/http_proxy.py
    
    * Update httpcore/_async/http_proxy.py
    
    * Update httpcore/_sync/http_proxy.py
    
    * Update httpcore/_backends/sync.py
    
    Co-authored-by: Kar Petrosyan <92274156+karosis88@users.noreply.github.com>
    
    ---------
    
    Co-authored-by: karosis88 <kar.petrosyanpy@gmail.com>
    Co-authored-by: Kar Petrosyan <92274156+karosis88@users.noreply.github.com>
    3 people authored Sep 1, 2023
    Copy the full SHA
    a42df6c View commit details

Commits on Sep 8, 2023

  1. Version 0.18.0 (#768)

    * new version
    
    * Update httpcore/__init__.py
    
    * Update CHANGELOG.md
    
    * Update CHANGELOG.md
    
    * reorder by importance
    
    ---------
    
    Co-authored-by: Tom Christie <tom@tomchristie.com>
    karpetrosyan and tomchristie authored Sep 8, 2023
    Copy the full SHA
    2d0945c View commit details
16 changes: 16 additions & 0 deletions .github/ISSUE_TEMPLATE/1-issue.md
Original file line number Diff line number Diff line change
@@ -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 #...
11 changes: 11 additions & 0 deletions .github/ISSUE_TEMPLATE/config.yml
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -17,7 +17,7 @@ jobs:
- 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"
2 changes: 1 addition & 1 deletion .github/workflows/test-suite.yml
Original file line number Diff line number Diff line change
@@ -14,7 +14,7 @@ jobs:

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"
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -4,12 +4,21 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## 0.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)

4 changes: 0 additions & 4 deletions MANIFEST.in

This file was deleted.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -25,7 +25,7 @@ Some things HTTP Core does do:

## Requirements

Python 3.7+
Python 3.8+

## Installation

2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
@@ -25,7 +25,7 @@ Some things HTTP Core does do:

## Requirements

Python 3.7+
Python 3.8+

## Installation

29 changes: 26 additions & 3 deletions docs/proxies.md
Original file line number Diff line number Diff line change
@@ -51,10 +51,33 @@ proxy = httpcore.HTTPProxy(
)
```

## 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

2 changes: 1 addition & 1 deletion httpcore/__init__.py
Original file line number Diff line number Diff line change
@@ -130,7 +130,7 @@ def __init__(self, *args, **kwargs): # type: ignore
"WriteError",
]

__version__ = "0.17.3"
__version__ = "0.18.0"


__locals = locals()
11 changes: 9 additions & 2 deletions httpcore/_async/connection.py
Original file line number Diff line number Diff line change
@@ -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):
20 changes: 16 additions & 4 deletions httpcore/_async/http11.py
Original file line number Diff line number Diff line change
@@ -20,6 +20,7 @@
ConnectionNotAvailable,
LocalProtocolError,
RemoteProtocolError,
WriteError,
map_exceptions,
)
from .._models import Origin, Request, Response
@@ -84,10 +85,21 @@ async def handle_async_request(self, request: Request) -> Response:

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:
20 changes: 19 additions & 1 deletion httpcore/_async/http_proxy.py
Original file line number Diff line number Diff line change
@@ -64,6 +64,7 @@ def __init__(
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 @@ def __init__(
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 @@ def __init__(
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 @@ def create_connection(self, origin: Origin) -> AsyncConnectionInterface:
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 @@ def __init__(
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 @@ def __init__(
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 @@ def __init__(
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
4 changes: 3 additions & 1 deletion httpcore/_async/socks_proxy.py
Original file line number Diff line number Diff line change
@@ -216,6 +216,7 @@ def __init__(

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 @@ async def handle_async_request(self, request: Request) -> Response:

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:
120 changes: 116 additions & 4 deletions httpcore/_backends/sync.py
Original file line number Diff line number Diff line change
@@ -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 @@ def start_tls(
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
3 changes: 2 additions & 1 deletion httpcore/_models.py
Original file line number Diff line number Diff line change
@@ -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:
11 changes: 9 additions & 2 deletions httpcore/_sync/connection.py
Original file line number Diff line number Diff line change
@@ -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):
20 changes: 16 additions & 4 deletions httpcore/_sync/http11.py
Original file line number Diff line number Diff line change
@@ -20,6 +20,7 @@
ConnectionNotAvailable,
LocalProtocolError,
RemoteProtocolError,
WriteError,
map_exceptions,
)
from .._models import Origin, Request, Response
@@ -84,10 +85,21 @@ def handle_request(self, request: Request) -> Response:

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:
20 changes: 19 additions & 1 deletion httpcore/_sync/http_proxy.py
Original file line number Diff line number Diff line change
@@ -64,6 +64,7 @@ def __init__(
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 @@ def __init__(
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 @@ def __init__(
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 @@ def create_connection(self, origin: Origin) -> ConnectionInterface:
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 @@ def __init__(
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 @@ def __init__(
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 @@ def __init__(
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
4 changes: 3 additions & 1 deletion httpcore/_sync/socks_proxy.py
Original file line number Diff line number Diff line change
@@ -216,6 +216,7 @@ def __init__(

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 @@ def handle_request(self, request: Request) -> Response:

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:
112 changes: 112 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = "tom@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
17 changes: 7 additions & 10 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -5,25 +5,22 @@ trio==0.21.0

# 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
2 changes: 1 addition & 1 deletion scripts/build
Original file line number Diff line number Diff line change
@@ -10,6 +10,6 @@ fi

set -x

${PREFIX}python setup.py sdist bdist_wheel
${PREFIX}python -m build
${PREFIX}twine check dist/*
${PREFIX}mkdocs build
5 changes: 2 additions & 3 deletions scripts/check
Original file line number Diff line number Diff line change
@@ -8,8 +8,7 @@ export SOURCE_FILES="httpcore tests"

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
5 changes: 2 additions & 3 deletions scripts/lint
Original file line number Diff line number Diff line change
@@ -8,9 +8,8 @@ export SOURCE_FILES="httpcore tests"

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.)
36 changes: 0 additions & 36 deletions setup.cfg

This file was deleted.

83 changes: 0 additions & 83 deletions setup.py

This file was deleted.

105 changes: 105 additions & 0 deletions tests/_async/test_connection.py
Original file line number Diff line number Diff line change
@@ -9,10 +9,13 @@
SOCKET_OPTION,
AsyncHTTPConnection,
AsyncMockBackend,
AsyncMockStream,
AsyncNetworkStream,
ConnectError,
ConnectionNotAvailable,
Origin,
RemoteProtocolError,
WriteError,
)


@@ -83,7 +86,109 @@ async def test_concurrent_requests_not_available_on_http11_connections():
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(
105 changes: 105 additions & 0 deletions tests/_sync/test_connection.py
Original file line number Diff line number Diff line change
@@ -9,10 +9,13 @@
SOCKET_OPTION,
HTTPConnection,
MockBackend,
MockStream,
NetworkStream,
ConnectError,
ConnectionNotAvailable,
Origin,
RemoteProtocolError,
WriteError,
)


@@ -83,7 +86,109 @@ def test_concurrent_requests_not_available_on_http11_connections():
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(
18 changes: 13 additions & 5 deletions unasync.py
Original file line number Diff line number Diff line change
@@ -2,22 +2,19 @@
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'),
('async for', 'for'),
('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 @@ def main():
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()