From ca7b92b267093d7fd116dd6facda46b08e50c5d7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 20 Feb 2026 14:46:58 +0000 Subject: [PATCH 01/15] Use mkdocs2 for documentation builds --- .github/workflows/docs.yml | 28 +++++++ .gitignore | 2 +- README.md | 10 --- docs/templates/base.html | 10 ++- mkdocs.toml | 6 ++ requirements.txt | 4 +- scripts/docs | 153 ------------------------------------- 7 files changed, 44 insertions(+), 169 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 mkdocs.toml delete mode 100755 scripts/docs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..6de5b3c --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,28 @@ +name: Documentation +on: + push: + branches: + - dev +permissions: + contents: read + pages: write + id-token: write +jobs: + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + steps: + - uses: actions/configure-pages@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v5 + with: + python-version: 3.x + - run: pip install -r requirements.txt + - run: mkdocs build + - uses: actions/upload-pages-artifact@v4 + with: + path: site + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/.gitignore b/.gitignore index f9d43a1..65f9ba1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ __pycache__/ dist/ venv/ -build/ +site/ diff --git a/README.md b/README.md index f6f9926..4f88bbc 100644 --- a/README.md +++ b/README.md @@ -59,14 +59,4 @@ The [HTTPX 1.0 design proposal](https://www.encode.io/httpnext/) is now availabl --- -# Collaboration - -The repository for this project is currently private. - -We’re looking at creating paid opportunities for working on open source software *which are properly compensated, flexible & well balanced.* - -If you're interested in a position working on this project, please send an intro. - ---- -

This provisional design work is not currently licensed for reuse.
Designed & crafted with care.

— 🦋 —

diff --git a/docs/templates/base.html b/docs/templates/base.html index 22fe4d3..64c57f4 100644 --- a/docs/templates/base.html +++ b/docs/templates/base.html @@ -86,6 +86,12 @@ color: #474747; } + h1 a, h2 a, h3 a, h4 a { + color: #eee; + font-weight: 300; + text-decoration: none; + } + h1 { text-align: center; font-weight: 300; font-size: 4rem; } h2 { font-size: 2rem; } h3 { font-size: 1.5rem; } @@ -174,7 +180,7 @@
- {{ content }} + {{ page.html }}
- \ No newline at end of file + diff --git a/mkdocs.toml b/mkdocs.toml new file mode 100644 index 0000000..407b0bc --- /dev/null +++ b/mkdocs.toml @@ -0,0 +1,6 @@ +[mkdocs] +nav = [] + +[loaders] +theme = "dir://docs" +docs = "dir://docs" diff --git a/requirements.txt b/requirements.txt index f4d4bb3..8fd226d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,4 @@ pytest-cov==6.1.1 unasync==0.6.0 # Documentation... -click==8.2.1 -jinja2==3.1.6 -markdown==3.8 +git+https://github.com/encode/mkdocs.git diff --git a/scripts/docs b/scripts/docs deleted file mode 100755 index 8c53da4..0000000 --- a/scripts/docs +++ /dev/null @@ -1,153 +0,0 @@ -#!venv/bin/python -import pathlib -import posixpath - -import click -import ghp_import -import logging -import httpx -import jinja2 -import markdown - -import xml.etree.ElementTree as etree - - -pages = { - '/': 'docs/index.md', - '/quickstart': 'docs/quickstart.md', - '/clients': 'docs/clients.md', - '/servers': 'docs/servers.md', - '/requests': 'docs/requests.md', - '/responses': 'docs/responses.md', - '/urls': 'docs/urls.md', - '/headers': 'docs/headers.md', - '/content-types': 'docs/content-types.md', - '/streams': 'docs/streams.md', - '/connections': 'docs/connections.md', - '/parsers': 'docs/parsers.md', - '/networking': 'docs/networking.md', - '/about': 'docs/about.md', -} - -def path_to_url(path): - if path == "index.md": - return "/" - return f"/{path[:-3]}" - - -class URLsProcessor(markdown.treeprocessors.Treeprocessor): - def __init__(self, state): - self.state = state - - def run(self, root: etree.Element) -> etree.Element: - for element in root.iter(): - if element.tag == 'a': - key = 'href' - elif element.tag == 'img': - key = 'src' - else: - continue - - url_or_path = element.get(key) - if url_or_path is not None: - output_url = self.rewrite_url(url_or_path) - element.set(key, output_url) - - return root - - def rewrite_url(self, href: str) -> str: - if not href.endswith('.md'): - return href - - current_url = path_to_url(self.state.file) - linked_url = path_to_url(href) - return posixpath.relpath(linked_url, start=current_url) - - -class BuildState: - def __init__(self): - self.file = '' - - -state = BuildState() -env = jinja2.Environment( - loader=jinja2.FileSystemLoader('docs/templates'), - autoescape=False -) -template = env.get_template('base.html') -md = markdown.Markdown(extensions=['fenced_code']) -md.treeprocessors.register( - item=URLsProcessor(state), - name='urls', - priority=10, -) - - -def not_found(): - text = httpx.Text('Not Found') - return httpx.Response(404, content=text) - - -def web_server(request): - if request.url.path not in pages: - return not_found() - - file = pages[request.url.path] - text = pathlib.Path(file).read_text() - - state.file = file - content = md.convert(text) - html = template.render(content=content).encode('utf-8') - content = httpx.HTML(html) - return httpx.Response(200, content=html) - - -@click.group() -def main(): - pass - - -@main.command() -def build(): - pathlib.Path("build").mkdir(exist_ok=True) - - for url, path in pages.items(): - basename = url.lstrip("/") - output = f"build/{basename}.html" if basename else "build/index.html" - text = pathlib.Path(path).read_text() - content = md.convert(text) - html = template.render(content=content) - pathlib.Path(output).write_text(html) - print(f"Built {output}") - - -@main.command() -def serve(): - logging.basicConfig( - format="%(levelname)s [%(asctime)s] %(name)s - %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - level=logging.INFO - ) - - with httpx.serve_http(web_server) as server: - server.wait() - - -@main.command() -def deploy(): - ghp_import.ghp_import( - "build", - mesg="Documentation deploy", - remote="origin", - branch="gh-pages", - push=True, - force=False, - use_shell=False, - no_history=False, - nojekyll=True, - ) - print(f"Deployed to GitHub") - - -if __name__ == "__main__": - main() From 425ba5abef0ac1bacf9405c9715999c5b27687e4 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 25 Feb 2026 17:00:47 +0000 Subject: [PATCH 02/15] Trio support --- src/ahttpx/_network.py | 74 +++--- src/ahttpx/_parsers.py | 22 ++ src/ahttpx/_server.py | 9 +- src/httpx/_parsers.py | 22 ++ src/httpx/_server.py | 9 +- tests/test_client.py | 80 +++--- tests/test_content.py | 165 ++++++------ tests/test_headers.py | 48 ++-- tests/test_network.py | 55 ++-- tests/test_parsers.py | 549 ++++++++++++++++++++------------------- tests/test_pool.py | 63 +++-- tests/test_quickstart.py | 43 +-- tests/test_request.py | 46 ++-- tests/test_response.py | 44 ++-- tests/test_streams.py | 85 +++--- tests/test_urlencode.py | 10 +- tests/test_urls.py | 78 +++--- 17 files changed, 766 insertions(+), 636 deletions(-) diff --git a/src/ahttpx/_network.py b/src/ahttpx/_network.py index 957e036..da3cd8a 100644 --- a/src/ahttpx/_network.py +++ b/src/ahttpx/_network.py @@ -1,8 +1,8 @@ -import asyncio import ssl import types import typing +import trio import certifi from ._streams import Stream @@ -13,39 +13,35 @@ class NetworkStream(Stream): def __init__( - self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter, address: str = '' + self, trio_stream: trio.abc.Stream, address: str = '' ) -> None: - self._reader = reader - self._writer = writer + self._trio_stream = trio_stream self._address = address - self._tls = False self._closed = False async def read(self, size: int = -1) -> bytes: if size < 0: size = 64 * 1024 - return await self._reader.read(size) + return await self._trio_stream.receive_some(size) async def write(self, buffer: bytes) -> None: - self._writer.write(buffer) - await self._writer.drain() + await self._trio_stream.send_all(buffer) async def close(self) -> None: - if not self._closed: - self._writer.close() - await self._writer.wait_closed() - self._closed = True + # Close the NetworkStream. + # If the stream is already closed this is a checkpointed no-op. + await self._trio_stream.aclose() + self._closed = True def __repr__(self): description = "" - description += " TLS" if self._tls else "" description += " CLOSED" if self._closed else "" - return f"" + return f"" def __del__(self): if not self._closed: import warnings - warnings.warn("NetworkStream was garbage collected without being closed.") + warnings.warn(f"{self!r} was garbage collected without being closed.") # Context managed usage... async def __aenter__(self) -> "NetworkStream": @@ -61,13 +57,17 @@ async def __aexit__( class NetworkServer: - def __init__(self, host: str, port: int, server: asyncio.Server): + def __init__(self, host: str, port: int, handler, listeners: list[trio.SocketListener]): self.host = host self.port = port - self._server = server + self._handler = handler + self._listeners = listeners # Context managed usage... async def __aenter__(self) -> "NetworkServer": + self._nursery_manager = trio.open_nursery() + self._nursery = await self._nursery_manager.__aenter__() + self._nursery.start_soon(trio.serve_listeners, self._handler, self._listeners) return self async def __aexit__( @@ -76,8 +76,8 @@ async def __aexit__( exc_value: BaseException | None = None, traceback: types.TracebackType | None = None, ): - self._server.close() - await self._server.wait_closed() + self._nursery.cancel_scope.cancel() + await self._nursery_manager.__aexit__(exc_type, exc_value, traceback) class NetworkBackend: @@ -92,29 +92,39 @@ async def connect(self, host: str, port: int) -> NetworkStream: """ Connect to the given address, returning a Stream instance. """ + # Create the TCP stream address = f"{host}:{port}" - reader, writer = await asyncio.open_connection(host, port) - return NetworkStream(reader, writer, address=address) + trio_stream = await trio.open_tcp_stream(host, port) + return NetworkStream(trio_stream, address=address) async def connect_tls(self, host: str, port: int, hostname: str = '') -> NetworkStream: """ Connect to the given address, returning a Stream instance. """ + # Create the TCP stream address = f"{host}:{port}" - reader, writer = await asyncio.open_connection(host, port) - await writer.start_tls(self._ssl_ctx, server_hostname=hostname) - return NetworkStream(reader, writer, address=address) + trio_stream = await trio.open_tcp_stream(host, port) + + # Establish SSL over TCP + hostname = hostname or host + ssl_stream = trio.SSLStream(trio_stream, ssl_context=self._ssl_ctx, server_hostname=hostname) + await ssl_stream.do_handshake() + + return NetworkStream(ssl_stream, address=address) async def serve(self, host: str, port: int, handler: typing.Callable[[NetworkStream], None]) -> NetworkServer: - async def callback(reader, writer): - stream = NetworkStream(reader, writer) + async def callback(trio_stream): + stream = NetworkStream(trio_stream, address=f"{host}:{port}") await handler(stream) - server = await asyncio.start_server(callback, host, port) - return NetworkServer(host, port, server) + listeners = await trio.open_tcp_listeners(port=port, host=host) + return NetworkServer(host, port, callback, listeners) + + def __repr__(self): + return f"" -Semaphore = asyncio.Semaphore -Lock = asyncio.Lock -timeout = asyncio.timeout -sleep = asyncio.sleep +Semaphore = trio.Semaphore +Lock = trio.Lock +timeout = trio.move_on_after +sleep = trio.sleep diff --git a/src/ahttpx/_parsers.py b/src/ahttpx/_parsers.py index 8a52a56..440f810 100644 --- a/src/ahttpx/_parsers.py +++ b/src/ahttpx/_parsers.py @@ -224,6 +224,16 @@ async def send_body(self, body: bytes) -> None: # Handle body close self.send_state = State.DONE + async def recv_close(self) -> bool: + # ... + if self.is_closed(): + return True + + if await self.parser.read_eof(): + await self.close() + return True + return False + async def recv_method_line(self) -> tuple[bytes, bytes, bytes]: """ Receive the initial request method line: @@ -463,6 +473,18 @@ async def read(self, size: int) -> bytes: self._push_back(bytes(push_back)) return bytes(buffer) + async def read_eof(self) -> bool: + """ + Attempt to read the closing EOF. + Return True if the stream is EOF, or False otherwise. + """ + if not self._buffer: + chunk = await self._read_some() + if not chunk: + return True + self._push_back(chunk) + return False + async def read_until(self, marker: bytes, max_size: int, exc_text: str) -> bytes: """ Read and return bytes from the stream, delimited by marker. diff --git a/src/ahttpx/_server.py b/src/ahttpx/_server.py index a9103cc..6591e31 100644 --- a/src/ahttpx/_server.py +++ b/src/ahttpx/_server.py @@ -31,7 +31,7 @@ def __init__(self, stream, endpoint): # API entry points... async def handle_requests(self): try: - while not self._parser.is_closed(): + while not await self._parser.recv_close(): method, url, headers = await self._recv_head() stream = HTTPStream(self._recv_body, self._complete) # TODO: Handle endpoint exceptions @@ -43,13 +43,14 @@ async def handle_requests(self): except Exception: logger.error("Internal Server Error", exc_info=True) content = Text("Internal Server Error") - err = Response(code=500, content=content) + err = Response(500, content=content) await self._send_head(err) await self._send_body(err) else: await self._send_head(response) await self._send_body(response) - except Exception: + except BaseException: + await self._stream.close() logger.error("Internal Server Error", exc_info=True) async def close(self): @@ -89,7 +90,7 @@ async def _send_body(self, response: Response): # Start it all over again... async def _complete(self): - await self._parser.complete + await self._parser.complete() self._idle_expiry = time.monotonic() + self._keepalive_duration diff --git a/src/httpx/_parsers.py b/src/httpx/_parsers.py index 830fccd..b8be024 100644 --- a/src/httpx/_parsers.py +++ b/src/httpx/_parsers.py @@ -224,6 +224,16 @@ def send_body(self, body: bytes) -> None: # Handle body close self.send_state = State.DONE + def recv_close(self) -> bool: + # ... + if self.is_closed(): + return True + + if self.parser.read_eof(): + self.close() + return True + return False + def recv_method_line(self) -> tuple[bytes, bytes, bytes]: """ Receive the initial request method line: @@ -463,6 +473,18 @@ def read(self, size: int) -> bytes: self._push_back(bytes(push_back)) return bytes(buffer) + def read_eof(self) -> bool: + """ + Attempt to read the closing EOF. + Return True if the stream is EOF, or False otherwise. + """ + if not self._buffer: + chunk = self._read_some() + if not chunk: + return True + self._push_back(chunk) + return False + def read_until(self, marker: bytes, max_size: int, exc_text: str) -> bytes: """ Read and return bytes from the stream, delimited by marker. diff --git a/src/httpx/_server.py b/src/httpx/_server.py index 95226d9..81efe58 100644 --- a/src/httpx/_server.py +++ b/src/httpx/_server.py @@ -31,7 +31,7 @@ def __init__(self, stream, endpoint): # API entry points... def handle_requests(self): try: - while not self._parser.is_closed(): + while not self._parser.recv_close(): method, url, headers = self._recv_head() stream = HTTPStream(self._recv_body, self._complete) # TODO: Handle endpoint exceptions @@ -43,13 +43,14 @@ def handle_requests(self): except Exception: logger.error("Internal Server Error", exc_info=True) content = Text("Internal Server Error") - err = Response(code=500, content=content) + err = Response(500, content=content) self._send_head(err) self._send_body(err) else: self._send_head(response) self._send_body(response) - except Exception: + except BaseException: + self._stream.close() logger.error("Internal Server Error", exc_info=True) def close(self): @@ -89,7 +90,7 @@ def _send_body(self, response: Response): # Start it all over again... def _complete(self): - self._parser.complete + self._parser.complete() self._idle_expiry = time.monotonic() + self._keepalive_duration diff --git a/tests/test_client.py b/tests/test_client.py index c26f6ba..ad1c23c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,11 +1,11 @@ import json -import httpx +import ahttpx import pytest -def echo(request): - request.read() - response = httpx.Response(200, content=httpx.JSON({ +async def echo(request): + await request.read() + response = ahttpx.Response(200, content=ahttpx.JSON({ 'method': request.method, 'query-params': dict(request.url.params.items()), 'content-type': request.headers.get('Content-Type'), @@ -15,31 +15,41 @@ def echo(request): @pytest.fixture -def client(): - with httpx.Client() as client: +async def client(): + async with ahttpx.Client() as client: yield client @pytest.fixture -def server(): - with httpx.serve_http(echo) as server: +async def server(): + async with ahttpx.serve_http(echo) as server: yield server -def test_client(client): +@pytest.mark.trio +async def test_client(client): assert repr(client) == "" -def test_get(client, server): - r = client.get(server.url) - assert r.status_code == 200 - assert r.body == b'{"method":"GET","query-params":{},"content-type":null,"json":null}' - assert r.text == '{"method":"GET","query-params":{},"content-type":null,"json":null}' +@pytest.mark.trio +async def test_get(): + async with ahttpx.serve_http(echo) as server: + async with ahttpx.Client() as client: + r = await client.get(server.url) + + return + async with ahttpx.serve_http(echo) as server: + async with ahttpx.Client() as client: + r = await client.get(server.url) + assert r.status_code == 200 + assert r.body == b'{"method":"GET","query-params":{},"content-type":null,"json":null}' + assert r.text == '{"method":"GET","query-params":{},"content-type":null,"json":null}' -def test_post(client, server): - data = httpx.JSON({"data": 123}) - r = client.post(server.url, content=data) +@pytest.mark.trio +async def test_post(client, server): + data = ahttpx.JSON({"data": 123}) + r = await client.post(server.url, content=data) assert r.status_code == 200 assert json.loads(r.body) == { 'method': 'POST', @@ -49,9 +59,10 @@ def test_post(client, server): } -def test_put(client, server): - data = httpx.JSON({"data": 123}) - r = client.put(server.url, content=data) +@pytest.mark.trio +async def test_put(client, server): + data = ahttpx.JSON({"data": 123}) + r = await client.put(server.url, content=data) assert r.status_code == 200 assert json.loads(r.body) == { 'method': 'PUT', @@ -61,9 +72,10 @@ def test_put(client, server): } -def test_patch(client, server): - data = httpx.JSON({"data": 123}) - r = client.patch(server.url, content=data) +@pytest.mark.trio +async def test_patch(client, server): + data = ahttpx.JSON({"data": 123}) + r = await client.patch(server.url, content=data) assert r.status_code == 200 assert json.loads(r.body) == { 'method': 'PATCH', @@ -73,8 +85,9 @@ def test_patch(client, server): } -def test_delete(client, server): - r = client.delete(server.url) +@pytest.mark.trio +async def test_delete(client, server): + r = await client.delete(server.url) assert r.status_code == 200 assert json.loads(r.body) == { 'method': 'DELETE', @@ -84,8 +97,9 @@ def test_delete(client, server): } -def test_request(client, server): - r = client.request("GET", server.url) +@pytest.mark.trio +async def test_request(client, server): + r = await client.request("GET", server.url) assert r.status_code == 200 assert json.loads(r.body) == { 'method': 'GET', @@ -95,10 +109,11 @@ def test_request(client, server): } -def test_stream(client, server): - with client.stream("GET", server.url) as r: +@pytest.mark.trio +async def test_stream(client, server): + async with await client.stream("GET", server.url) as r: assert r.status_code == 200 - r.read() + await r.read() assert json.loads(r.body) == { 'method': 'GET', 'query-params': {}, @@ -107,6 +122,7 @@ def test_stream(client, server): } -def test_get_with_invalid_scheme(client): +@pytest.mark.trio +async def test_get_with_invalid_scheme(client): with pytest.raises(ValueError): - client.get("nope://www.example.com") + await client.get("nope://www.example.com") diff --git a/tests/test_content.py b/tests/test_content.py index ae3158e..f8a1500 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -1,49 +1,52 @@ -import httpx +import ahttpx import os import tempfile - +import pytest # HTML -def test_html(): - html = httpx.HTML("Hello, world") +@pytest.mark.trio +async def test_html(): + html = ahttpx.HTML("Hello, world") stream = html.encode() content_type = html.content_type() - assert stream.read() == b'Hello, world' + assert await stream.read() == b'Hello, world' assert content_type == "text/html; charset='utf-8'" # Text -def test_text(): - text = httpx.Text("Hello, world") +@pytest.mark.trio +async def test_text(): + text = ahttpx.Text("Hello, world") stream = text.encode() content_type = text.content_type() - assert stream.read() == b'Hello, world' + assert await stream.read() == b'Hello, world' assert content_type == "text/plain; charset='utf-8'" # JSON -def test_json(): - data = httpx.JSON({'data': 123}) +@pytest.mark.trio +async def test_json(): + data = ahttpx.JSON({'data': 123}) stream = data.encode() content_type = data.content_type() - assert stream.read() == b'{"data":123}' + assert await stream.read() == b'{"data":123}' assert content_type == "application/json" # Form def test_form(): - f = httpx.Form("a=123&a=456&b=789") + f = ahttpx.Form("a=123&a=456&b=789") assert str(f) == "a=123&a=456&b=789" assert repr(f) == "
" assert f.multi_dict() == { @@ -53,7 +56,7 @@ def test_form(): def test_form_from_dict(): - f = httpx.Form({ + f = ahttpx.Form({ "a": ["123", "456"], "b": "789" }) @@ -66,7 +69,7 @@ def test_form_from_dict(): def test_form_from_list(): - f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) + f = ahttpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) assert str(f) == "a=123&a=456&b=789" assert repr(f) == "" assert f.multi_dict() == { @@ -76,14 +79,14 @@ def test_form_from_list(): def test_empty_form(): - f = httpx.Form() + f = ahttpx.Form() assert str(f) == '' assert repr(f) == "" assert f.multi_dict() == {} def test_form_accessors(): - f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) + f = ahttpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) assert "a" in f assert "A" not in f assert "c" not in f @@ -93,7 +96,7 @@ def test_form_accessors(): def test_form_dict(): - f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) + f = ahttpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) assert list(f.keys()) == ["a", "b"] assert list(f.values()) == ["123", "789"] assert list(f.items()) == [("a", "123"), ("b", "789")] @@ -102,61 +105,62 @@ def test_form_dict(): def test_form_multidict(): - f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) + f = ahttpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) assert f.get_list("a") == ["123", "456"] assert f.multi_items() == [("a", "123"), ("a", "456"), ("b", "789")] assert f.multi_dict() == {"a": ["123", "456"], "b": ["789"]} def test_form_builtins(): - f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) + f = ahttpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) assert len(f) == 2 assert bool(f) assert hash(f) - assert f == httpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) + assert f == ahttpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) def test_form_copy_operations(): - f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) - assert f.copy_set("a", "abc") == httpx.Form([("a", "abc"), ("b", "789")]) - assert f.copy_append("a", "abc") == httpx.Form([("a", "123"), ("a", "456"), ("a", "abc"), ("b", "789")]) - assert f.copy_remove("a") == httpx.Form([("b", "789")]) + f = ahttpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) + assert f.copy_set("a", "abc") == ahttpx.Form([("a", "abc"), ("b", "789")]) + assert f.copy_append("a", "abc") == ahttpx.Form([("a", "123"), ("a", "456"), ("a", "abc"), ("b", "789")]) + assert f.copy_remove("a") == ahttpx.Form([("b", "789")]) -def test_form_encode(): - form = httpx.Form({'email': 'address@example.com'}) +@pytest.mark.trio +async def test_form_encode(): + form = ahttpx.Form({'email': 'address@example.com'}) assert form['email'] == "address@example.com" stream = form.encode() content_type = form.content_type() - assert stream.read() == b"email=address%40example.com" + assert await stream.read() == b"email=address%40example.com" assert content_type == "application/x-www-form-urlencoded" # Files def test_files(): - f = httpx.Files() + f = ahttpx.Files() assert f.multi_dict() == {} assert repr(f) == "" def test_files_from_dict(): - f = httpx.Files({ + f = ahttpx.Files({ "a": [ - httpx.File("123.json"), - httpx.File("456.json"), + ahttpx.File("123.json"), + ahttpx.File("456.json"), ], - "b": httpx.File("789.json") + "b": ahttpx.File("789.json") }) assert f.multi_dict() == { "a": [ - httpx.File("123.json"), - httpx.File("456.json"), + ahttpx.File("123.json"), + ahttpx.File("456.json"), ], "b": [ - httpx.File("789.json"), + ahttpx.File("789.json"), ] } assert repr(f) == ( @@ -166,18 +170,18 @@ def test_files_from_dict(): def test_files_from_list(): - f = httpx.Files([ - ("a", httpx.File("123.json")), - ("a", httpx.File("456.json")), - ("b", httpx.File("789.json")) + f = ahttpx.Files([ + ("a", ahttpx.File("123.json")), + ("a", ahttpx.File("456.json")), + ("b", ahttpx.File("789.json")) ]) assert f.multi_dict() == { "a": [ - httpx.File("123.json"), - httpx.File("456.json"), + ahttpx.File("123.json"), + ahttpx.File("456.json"), ], "b": [ - httpx.File("789.json"), + ahttpx.File("789.json"), ] } assert repr(f) == ( @@ -186,92 +190,93 @@ def test_files_from_list(): def test_files_accessors(): - f = httpx.Files([ - ("a", httpx.File("123.json")), - ("a", httpx.File("456.json")), - ("b", httpx.File("789.json")) + f = ahttpx.Files([ + ("a", ahttpx.File("123.json")), + ("a", ahttpx.File("456.json")), + ("b", ahttpx.File("789.json")) ]) assert "a" in f assert "A" not in f assert "c" not in f - assert f["a"] == httpx.File("123.json") - assert f.get("a") == httpx.File("123.json") + assert f["a"] == ahttpx.File("123.json") + assert f.get("a") == ahttpx.File("123.json") assert f.get("nope", default=None) is None def test_files_dict(): - f = httpx.Files([ - ("a", httpx.File("123.json")), - ("a", httpx.File("456.json")), - ("b", httpx.File("789.json")) + f = ahttpx.Files([ + ("a", ahttpx.File("123.json")), + ("a", ahttpx.File("456.json")), + ("b", ahttpx.File("789.json")) ]) assert list(f.keys()) == ["a", "b"] - assert list(f.values()) == [httpx.File("123.json"), httpx.File("789.json")] - assert list(f.items()) == [("a", httpx.File("123.json")), ("b", httpx.File("789.json"))] + assert list(f.values()) == [ahttpx.File("123.json"), ahttpx.File("789.json")] + assert list(f.items()) == [("a", ahttpx.File("123.json")), ("b", ahttpx.File("789.json"))] assert list(f) == ["a", "b"] - assert dict(f) == {"a": httpx.File("123.json"), "b": httpx.File("789.json")} + assert dict(f) == {"a": ahttpx.File("123.json"), "b": ahttpx.File("789.json")} def test_files_multidict(): - f = httpx.Files([ - ("a", httpx.File("123.json")), - ("a", httpx.File("456.json")), - ("b", httpx.File("789.json")) + f = ahttpx.Files([ + ("a", ahttpx.File("123.json")), + ("a", ahttpx.File("456.json")), + ("b", ahttpx.File("789.json")) ]) assert f.get_list("a") == [ - httpx.File("123.json"), - httpx.File("456.json"), + ahttpx.File("123.json"), + ahttpx.File("456.json"), ] assert f.multi_items() == [ - ("a", httpx.File("123.json")), - ("a", httpx.File("456.json")), - ("b", httpx.File("789.json")), + ("a", ahttpx.File("123.json")), + ("a", ahttpx.File("456.json")), + ("b", ahttpx.File("789.json")), ] assert f.multi_dict() == { "a": [ - httpx.File("123.json"), - httpx.File("456.json"), + ahttpx.File("123.json"), + ahttpx.File("456.json"), ], "b": [ - httpx.File("789.json"), + ahttpx.File("789.json"), ] } def test_files_builtins(): - f = httpx.Files([ - ("a", httpx.File("123.json")), - ("a", httpx.File("456.json")), - ("b", httpx.File("789.json")) + f = ahttpx.Files([ + ("a", ahttpx.File("123.json")), + ("a", ahttpx.File("456.json")), + ("b", ahttpx.File("789.json")) ]) assert len(f) == 2 assert bool(f) - assert f == httpx.Files([ - ("a", httpx.File("123.json")), - ("a", httpx.File("456.json")), - ("b", httpx.File("789.json")), + assert f == ahttpx.Files([ + ("a", ahttpx.File("123.json")), + ("a", ahttpx.File("456.json")), + ("b", ahttpx.File("789.json")), ]) -def test_multipart(): +@pytest.mark.trio +async def test_multipart(): with tempfile.NamedTemporaryFile() as f: f.write(b"Hello, world") f.seek(0) - multipart = httpx.MultiPart( + multipart = ahttpx.MultiPart( form={'email': 'me@example.com'}, - files={'upload': httpx.File(f.name)}, + files={'upload': ahttpx.File(f.name)}, boundary='BOUNDARY', ) assert multipart.form['email'] == "me@example.com" - assert multipart.files['upload'] == httpx.File(f.name) + assert multipart.files['upload'] == ahttpx.File(f.name) fname = os.path.basename(f.name).encode('utf-8') stream = multipart.encode() content_type = multipart.content_type() content_type == "multipart/form-data; boundary=BOUNDARY" - content = stream.read() + content = await stream.read() assert content == ( b'--BOUNDARY\r\n' b'Content-Disposition: form-data; name="email"\r\n' diff --git a/tests/test_headers.py b/tests/test_headers.py index 6ebb99d..ce991e0 100644 --- a/tests/test_headers.py +++ b/tests/test_headers.py @@ -1,9 +1,9 @@ -import httpx +import ahttpx import pytest def test_headers_from_dict(): - headers = httpx.Headers({ + headers = ahttpx.Headers({ 'Content-Length': '1024', 'Content-Type': 'text/plain; charset=utf-8', }) @@ -12,7 +12,7 @@ def test_headers_from_dict(): def test_headers_from_list(): - headers = httpx.Headers([ + headers = ahttpx.Headers([ ('Location', 'https://www.example.com'), ('Set-Cookie', 'session_id=3498jj489jhb98jn'), ]) @@ -21,49 +21,49 @@ def test_headers_from_list(): def test_header_keys(): - h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + h = ahttpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) assert list(h.keys()) == ["Accept", "User-Agent"] def test_header_values(): - h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + h = ahttpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) assert list(h.values()) == ["*/*", "python/httpx"] def test_header_items(): - h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + h = ahttpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) assert list(h.items()) == [("Accept", "*/*"), ("User-Agent", "python/httpx")] def test_header_get(): - h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + h = ahttpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) assert h.get("User-Agent") == "python/httpx" assert h.get("user-agent") == "python/httpx" assert h.get("missing") is None def test_header_copy_set(): - h = httpx.Headers({"Expires": "0"}) + h = ahttpx.Headers({"Expires": "0"}) h = h.copy_set("Expires", "Wed, 21 Oct 2015 07:28:00 GMT") - assert h == httpx.Headers({"Expires": "Wed, 21 Oct 2015 07:28:00 GMT"}) + assert h == ahttpx.Headers({"Expires": "Wed, 21 Oct 2015 07:28:00 GMT"}) - h = httpx.Headers({"Expires": "0"}) + h = ahttpx.Headers({"Expires": "0"}) h = h.copy_set("expires", "Wed, 21 Oct 2015 07:28:00 GMT") - assert h == httpx.Headers({"Expires": "Wed, 21 Oct 2015 07:28:00 GMT"}) + assert h == ahttpx.Headers({"Expires": "Wed, 21 Oct 2015 07:28:00 GMT"}) def test_header_copy_remove(): - h = httpx.Headers({"Accept": "*/*"}) + h = ahttpx.Headers({"Accept": "*/*"}) h = h.copy_remove("Accept") - assert h == httpx.Headers({}) + assert h == ahttpx.Headers({}) - h = httpx.Headers({"Accept": "*/*"}) + h = ahttpx.Headers({"Accept": "*/*"}) h = h.copy_remove("accept") - assert h == httpx.Headers({}) + assert h == ahttpx.Headers({}) def test_header_getitem(): - h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + h = ahttpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) assert h["User-Agent"] == "python/httpx" assert h["user-agent"] == "python/httpx" with pytest.raises(KeyError): @@ -71,39 +71,39 @@ def test_header_getitem(): def test_header_contains(): - h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + h = ahttpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) assert "User-Agent" in h assert "user-agent" in h assert "missing" not in h def test_header_bool(): - h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + h = ahttpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) assert bool(h) - h = httpx.Headers() + h = ahttpx.Headers() assert not bool(h) def test_header_iter(): - h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + h = ahttpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) assert [k for k in h] == ["Accept", "User-Agent"] def test_header_len(): - h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + h = ahttpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) assert len(h) == 2 def test_header_repr(): - h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + h = ahttpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) assert repr(h) == "" def test_header_invalid_name(): with pytest.raises(ValueError): - httpx.Headers({"Accept\n": "*/*"}) + ahttpx.Headers({"Accept\n": "*/*"}) def test_header_invalid_value(): with pytest.raises(ValueError): - httpx.Headers({"Accept": "*/*\n"}) + ahttpx.Headers({"Accept": "*/*\n"}) diff --git a/tests/test_network.py b/tests/test_network.py index e6ce925..0f27de5 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -1,54 +1,57 @@ -import httpx +import ahttpx import pytest -def echo(stream): - while buffer := stream.read(): - stream.write(buffer) +async def echo(stream): + while buffer := await stream.read(): + await stream.write(buffer) @pytest.fixture -def server(): - net = httpx.NetworkBackend() - with net.serve("127.0.0.1", 8080, echo) as server: +async def server(): + net = ahttpx.NetworkBackend() + async with await net.serve("127.0.0.1", 8080, echo) as server: yield server def test_network_backend(): - net = httpx.NetworkBackend() - assert repr(net) == "" + net = ahttpx.NetworkBackend() + assert repr(net) == "" -def test_network_backend_connect(server): - net = httpx.NetworkBackend() - stream = net.connect(server.host, server.port) +@pytest.mark.trio +async def test_network_backend_connect(server): + net = ahttpx.NetworkBackend() + stream = await net.connect(server.host, server.port) try: assert repr(stream) == f"" - stream.write(b"Hello, world.") - content = stream.read() + await stream.write(b"Hello, world.") + content = await stream.read() assert content == b"Hello, world." finally: - stream.close() + await stream.close() -def test_network_backend_context_managed(server): - net = httpx.NetworkBackend() - with net.connect(server.host, server.port) as stream: - stream.write(b"Hello, world.") - content = stream.read() +@pytest.mark.trio +async def test_network_backend_context_managed(server): + net = ahttpx.NetworkBackend() + async with await net.connect(server.host, server.port) as stream: + await stream.write(b"Hello, world.") + content = await stream.read() assert content == b"Hello, world." assert repr(stream) == f"" -def test_network_backend_timeout(server): - net = httpx.NetworkBackend() - with httpx.timeout(0.0): +@pytest.mark.trio +async def test_network_backend_timeout(server): + net = ahttpx.NetworkBackend() + with ahttpx.timeout(0.0): with pytest.raises(TimeoutError): - with net.connect(server.host, server.port) as stream: + async with await net.connect(server.host, server.port) as stream: pass - with httpx.timeout(10.0): - with net.connect(server.host, server.port) as stream: + with ahttpx.timeout(10.0): + async with await net.connect(server.host, server.port) as stream: pass diff --git a/tests/test_parsers.py b/tests/test_parsers.py index e2a321e..b2796ee 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -1,23 +1,24 @@ -import httpx +import ahttpx import pytest -class TrickleIO(httpx.Stream): - def __init__(self, stream: httpx.Stream): +class TrickleIO(ahttpx.Stream): + def __init__(self, stream: ahttpx.Stream): self._stream = stream - def read(self, size) -> bytes: - return self._stream.read(1) + async def read(self, size) -> bytes: + return await self._stream.read(1) - def write(self, data: bytes) -> None: - self._stream.write(data) + async def write(self, data: bytes) -> None: + await self._stream.write(data) - def close(self) -> None: - self._stream.close() + async def close(self) -> None: + await self._stream.close() -def test_parser(): - stream = httpx.DuplexStream( +@pytest.mark.trio +async def test_parser(): + stream = ahttpx.DuplexStream( b"HTTP/1.1 200 OK\r\n" b"Content-Length: 12\r\n" b"Content-Type: text/plain\r\n" @@ -25,15 +26,15 @@ def test_parser(): b"hello, world" ) - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"POST", b"/", b"HTTP/1.1") - p.send_headers([ + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"POST", b"/", b"HTTP/1.1") + await p.send_headers([ (b"Host", b"example.com"), (b"Content-Type", b"application/json"), (b"Content-Length", b"23"), ]) - p.send_body(b'{"msg": "hello, world"}') - p.send_body(b'') + await p.send_body(b'{"msg": "hello, world"}') + await p.send_body(b'') assert stream.input_bytes() == ( b"HTTP/1.1 200 OK\r\n" @@ -51,10 +52,10 @@ def test_parser(): b'{"msg": "hello, world"}' ) - protocol, code, reason_phase = p.recv_status_line() - headers = p.recv_headers() - body = p.recv_body() - terminator = p.recv_body() + protocol, code, reason_phase = await p.recv_status_line() + headers = await p.recv_headers() + body = await p.recv_body() + terminator = await p.recv_body() assert protocol == b'HTTP/1.1' assert code == 200 @@ -67,21 +68,22 @@ def test_parser(): assert terminator == b'' assert not p.is_idle() - p.complete() + await p.complete() assert p.is_idle() -def test_parser_server(): - stream = httpx.DuplexStream( +@pytest.mark.trio +async def test_parser_server(): + stream = ahttpx.DuplexStream( b"GET / HTTP/1.1\r\n" b"Host: www.example.com\r\n" b"\r\n" ) - p = httpx.HTTPParser(stream, mode='SERVER') - method, target, protocol = p.recv_method_line() - headers = p.recv_headers() - body = p.recv_body() + p = ahttpx.HTTPParser(stream, mode='SERVER') + method, target, protocol = await p.recv_method_line() + headers = await p.recv_headers() + body = await p.recv_body() assert method == b'GET' assert target == b'/' @@ -91,13 +93,13 @@ def test_parser_server(): ] assert body == b'' - p.send_status_line(b"HTTP/1.1", 200, b"OK") - p.send_headers([ + await p.send_status_line(b"HTTP/1.1", 200, b"OK") + await p.send_headers([ (b"Content-Type", b"application/json"), (b"Content-Length", b"23"), ]) - p.send_body(b'{"msg": "hello, world"}') - p.send_body(b'') + await p.send_body(b'{"msg": "hello, world"}') + await p.send_body(b'') assert stream.input_bytes() == ( b"GET / HTTP/1.1\r\n" @@ -113,12 +115,13 @@ def test_parser_server(): ) assert not p.is_idle() - p.complete() + await p.complete() assert p.is_idle() -def test_parser_trickle(): - stream = httpx.DuplexStream( +@pytest.mark.trio +async def test_parser_trickle(): + stream = ahttpx.DuplexStream( b"HTTP/1.1 200 OK\r\n" b"Content-Length: 12\r\n" b"Content-Type: text/plain\r\n" @@ -126,15 +129,15 @@ def test_parser_trickle(): b"hello, world" ) - p = httpx.HTTPParser(TrickleIO(stream), mode='CLIENT') - p.send_method_line(b"POST", b"/", b"HTTP/1.1") - p.send_headers([ + p = ahttpx.HTTPParser(TrickleIO(stream), mode='CLIENT') + await p.send_method_line(b"POST", b"/", b"HTTP/1.1") + await p.send_headers([ (b"Host", b"example.com"), (b"Content-Type", b"application/json"), (b"Content-Length", b"23"), ]) - p.send_body(b'{"msg": "hello, world"}') - p.send_body(b'') + await p.send_body(b'{"msg": "hello, world"}') + await p.send_body(b'') assert stream.input_bytes() == ( b"HTTP/1.1 200 OK\r\n" @@ -152,10 +155,10 @@ def test_parser_trickle(): b'{"msg": "hello, world"}' ) - protocol, code, reason_phase = p.recv_status_line() - headers = p.recv_headers() - body = p.recv_body() - terminator = p.recv_body() + protocol, code, reason_phase = await p.recv_status_line() + headers = await p.recv_headers() + body = await p.recv_body() + terminator = await p.recv_body() assert protocol == b'HTTP/1.1' assert code == 200 @@ -168,8 +171,9 @@ def test_parser_trickle(): assert terminator == b'' -def test_parser_transfer_encoding_chunked(): - stream = httpx.DuplexStream( +@pytest.mark.trio +async def test_parser_transfer_encoding_chunked(): + stream = ahttpx.DuplexStream( b"HTTP/1.1 200 OK\r\n" b"Content-Type: text/plain\r\n" b"Transfer-Encoding: chunked\r\n" @@ -179,15 +183,15 @@ def test_parser_transfer_encoding_chunked(): b"0\r\n\r\n" ) - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"POST", b"/", b"HTTP/1.1") - p.send_headers([ + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"POST", b"/", b"HTTP/1.1") + await p.send_headers([ (b"Host", b"example.com"), (b"Content-Type", b"application/json"), (b"Transfer-Encoding", b"chunked"), ]) - p.send_body(b'{"msg": "hello, world"}') - p.send_body(b'') + await p.send_body(b'{"msg": "hello, world"}') + await p.send_body(b'') assert stream.input_bytes() == ( b"HTTP/1.1 200 OK\r\n" @@ -209,10 +213,10 @@ def test_parser_transfer_encoding_chunked(): b'0\r\n\r\n' ) - protocol, code, reason_phase = p.recv_status_line() - headers = p.recv_headers() - body = p.recv_body() - terminator = p.recv_body() + protocol, code, reason_phase = await p.recv_status_line() + headers = await p.recv_headers() + body = await p.recv_body() + terminator = await p.recv_body() assert protocol == b'HTTP/1.1' assert code == 200 @@ -225,8 +229,9 @@ def test_parser_transfer_encoding_chunked(): assert terminator == b'' -def test_parser_transfer_encoding_chunked_trickle(): - stream = httpx.DuplexStream( +@pytest.mark.trio +async def test_parser_transfer_encoding_chunked_trickle(): + stream = ahttpx.DuplexStream( b"HTTP/1.1 200 OK\r\n" b"Content-Type: text/plain\r\n" b"Transfer-Encoding: chunked\r\n" @@ -236,15 +241,15 @@ def test_parser_transfer_encoding_chunked_trickle(): b"0\r\n\r\n" ) - p = httpx.HTTPParser(TrickleIO(stream), mode='CLIENT') - p.send_method_line(b"POST", b"/", b"HTTP/1.1") - p.send_headers([ + p = ahttpx.HTTPParser(TrickleIO(stream), mode='CLIENT') + await p.send_method_line(b"POST", b"/", b"HTTP/1.1") + await p.send_headers([ (b"Host", b"example.com"), (b"Content-Type", b"application/json"), (b"Transfer-Encoding", b"chunked"), ]) - p.send_body(b'{"msg": "hello, world"}') - p.send_body(b'') + await p.send_body(b'{"msg": "hello, world"}') + await p.send_body(b'') assert stream.input_bytes() == ( b"HTTP/1.1 200 OK\r\n" @@ -266,10 +271,10 @@ def test_parser_transfer_encoding_chunked_trickle(): b'0\r\n\r\n' ) - protocol, code, reason_phase = p.recv_status_line() - headers = p.recv_headers() - body = p.recv_body() - terminator = p.recv_body() + protocol, code, reason_phase = await p.recv_status_line() + headers = await p.recv_headers() + body = await p.recv_body() + terminator = await p.recv_body() assert protocol == b'HTTP/1.1' assert code == 200 @@ -282,8 +287,9 @@ def test_parser_transfer_encoding_chunked_trickle(): assert terminator == b'' -def test_parser_repr(): - stream = httpx.DuplexStream( +@pytest.mark.trio +async def test_parser_repr(): + stream = ahttpx.DuplexStream( b"HTTP/1.1 200 OK\r\n" b"Content-Type: application/json\r\n" b"Content-Length: 23\r\n" @@ -291,150 +297,155 @@ def test_parser_repr(): b'{"msg": "hello, world"}' ) - p = httpx.HTTPParser(stream, mode='CLIENT') + p = ahttpx.HTTPParser(stream, mode='CLIENT') assert repr(p) == "" - p.send_method_line(b"GET", b"/", b"HTTP/1.1") + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") assert repr(p) == "" - p.send_headers([(b"Host", b"example.com")]) + await p.send_headers([(b"Host", b"example.com")]) assert repr(p) == "" - p.send_body(b'') + await p.send_body(b'') assert repr(p) == "" - p.recv_status_line() + await p.recv_status_line() assert repr(p) == "" - p.recv_headers() + await p.recv_headers() assert repr(p) == "" - p.recv_body() + await p.recv_body() assert repr(p) == "" - p.recv_body() + await p.recv_body() assert repr(p) == "" - p.complete() + await p.complete() assert repr(p) == "" -def test_parser_invalid_transitions(): - stream = httpx.DuplexStream() +@pytest.mark.trio +async def test_parser_invalid_transitions(): + stream = ahttpx.DuplexStream() - with pytest.raises(httpx.ProtocolError): - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b'GET', b'/', b'HTTP/1.1') - p.send_method_line(b'GET', b'/', b'HTTP/1.1') + with pytest.raises(ahttpx.ProtocolError): + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b'GET', b'/', b'HTTP/1.1') + await p.send_method_line(b'GET', b'/', b'HTTP/1.1') - with pytest.raises(httpx.ProtocolError): - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_headers([]) + with pytest.raises(ahttpx.ProtocolError): + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_headers([]) - with pytest.raises(httpx.ProtocolError): - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_body(b'') + with pytest.raises(ahttpx.ProtocolError): + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_body(b'') - with pytest.raises(httpx.ProtocolError): - reader = httpx.ByteStream(b'HTTP/1.1 200 OK\r\n') - p = httpx.HTTPParser(stream, mode='CLIENT') - p.recv_status_line() + with pytest.raises(ahttpx.ProtocolError): + reader = ahttpx.ByteStream(b'HTTP/1.1 200 OK\r\n') + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.recv_status_line() - with pytest.raises(httpx.ProtocolError): - p = httpx.HTTPParser(stream, mode='CLIENT') - p.recv_headers() + with pytest.raises(ahttpx.ProtocolError): + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.recv_headers() - with pytest.raises(httpx.ProtocolError): - p = httpx.HTTPParser(stream, mode='CLIENT') - p.recv_body() + with pytest.raises(ahttpx.ProtocolError): + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.recv_body() -def test_parser_invalid_status_line(): +@pytest.mark.trio +async def test_parser_invalid_status_line(): # ... - stream = httpx.DuplexStream(b'...') + stream = ahttpx.DuplexStream(b'...') - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") - p.send_headers([(b"Host", b"example.com")]) - p.send_body(b'') + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") + await p.send_headers([(b"Host", b"example.com")]) + await p.send_body(b'') msg = 'Stream closed early reading response status line' - with pytest.raises(httpx.ProtocolError, match=msg): - p.recv_status_line() + with pytest.raises(ahttpx.ProtocolError, match=msg): + await p.recv_status_line() # ... - stream = httpx.DuplexStream(b'HTTP/1.1' + b'x' * 5000) + stream = ahttpx.DuplexStream(b'HTTP/1.1' + b'x' * 5000) - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") - p.send_headers([(b"Host", b"example.com")]) - p.send_body(b'') + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") + await p.send_headers([(b"Host", b"example.com")]) + await p.send_body(b'') msg = 'Exceeded maximum size reading response status line' - with pytest.raises(httpx.ProtocolError, match=msg): - p.recv_status_line() + with pytest.raises(ahttpx.ProtocolError, match=msg): + await p.recv_status_line() # ... - stream = httpx.DuplexStream(b'HTTP/1.1' + b'x' * 5000 + b'\r\n') + stream = ahttpx.DuplexStream(b'HTTP/1.1' + b'x' * 5000 + b'\r\n') - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") - p.send_headers([(b"Host", b"example.com")]) - p.send_body(b'') + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") + await p.send_headers([(b"Host", b"example.com")]) + await p.send_body(b'') msg = 'Exceeded maximum size reading response status line' - with pytest.raises(httpx.ProtocolError, match=msg): - p.recv_status_line() + with pytest.raises(ahttpx.ProtocolError, match=msg): + await p.recv_status_line() -def test_parser_sent_unsupported_protocol(): +@pytest.mark.trio +async def test_parser_sent_unsupported_protocol(): # Currently only HTTP/1.1 is supported. - stream = httpx.DuplexStream() + stream = ahttpx.DuplexStream() - p = httpx.HTTPParser(stream, mode='CLIENT') + p = ahttpx.HTTPParser(stream, mode='CLIENT') msg = 'Sent unsupported protocol version' - with pytest.raises(httpx.ProtocolError, match=msg): - p.send_method_line(b"GET", b"/", b"HTTP/1.0") + with pytest.raises(ahttpx.ProtocolError, match=msg): + await p.send_method_line(b"GET", b"/", b"HTTP/1.0") -def test_parser_recv_unsupported_protocol(): +@pytest.mark.trio +async def test_parser_recv_unsupported_protocol(): # Currently only HTTP/1.1 is supported. - stream = httpx.DuplexStream(b"HTTP/1.0 200 OK\r\n") + stream = ahttpx.DuplexStream(b"HTTP/1.0 200 OK\r\n") - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") msg = 'Received unsupported protocol version' - with pytest.raises(httpx.ProtocolError, match=msg): - p.recv_status_line() + with pytest.raises(ahttpx.ProtocolError, match=msg): + await p.recv_status_line() -def test_parser_large_body(): +@pytest.mark.trio +async def test_parser_large_body(): body = b"x" * 6988 - stream = httpx.DuplexStream( + stream = ahttpx.DuplexStream( b"HTTP/1.1 200 OK\r\n" b"Content-Length: 6988\r\n" b"Content-Type: text/plain\r\n" b"\r\n" + body ) - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") - p.send_headers([(b"Host", b"example.com")]) - p.send_body(b'') + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") + await p.send_headers([(b"Host", b"example.com")]) + await p.send_body(b'') # Checkout our buffer sizes. - p.recv_status_line() - p.recv_headers() - assert len(p.recv_body()) == 4096 - assert len(p.recv_body()) == 2892 - assert len(p.recv_body()) == 0 - - -def test_parser_stream_large_body(): + await p.recv_status_line() + await p.recv_headers() + assert len(await p.recv_body()) == 4096 + assert len(await p.recv_body()) == 2892 + assert len(await p.recv_body()) == 0 + +@pytest.mark.trio +async def test_parser_stream_large_body(): body = b"x" * 6956 - stream = httpx.DuplexStream( + stream = ahttpx.DuplexStream( b"HTTP/1.1 200 OK\r\n" b"Transfer-Encoding: chunked\r\n" b"Content-Type: text/plain\r\n" @@ -442,22 +453,23 @@ def test_parser_stream_large_body(): b"1b2c\r\n" + body + b'\r\n0\r\n\r\n' ) - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") - p.send_headers([(b"Host", b"example.com")]) - p.send_body(b'') + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") + await p.send_headers([(b"Host", b"example.com")]) + await p.send_body(b'') # Checkout our buffer sizes. - p.recv_status_line() - p.recv_headers() + await p.recv_status_line() + await p.recv_headers() # assert len(p.recv_body()) == 4096 # assert len(p.recv_body()) == 2860 - assert len(p.recv_body()) == 6956 - assert len(p.recv_body()) == 0 + assert len(await p.recv_body()) == 6956 + assert len(await p.recv_body()) == 0 -def test_parser_not_enough_data_received(): - stream = httpx.DuplexStream( +@pytest.mark.trio +async def test_parser_not_enough_data_received(): + stream = ahttpx.DuplexStream( b"HTTP/1.1 200 OK\r\n" b"Content-Length: 188\r\n" b"Content-Type: text/plain\r\n" @@ -465,63 +477,67 @@ def test_parser_not_enough_data_received(): b"truncated" ) - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") - p.send_headers([(b"Host", b"example.com")]) - p.send_body(b'') + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") + await p.send_headers([(b"Host", b"example.com")]) + await p.send_body(b'') # Checkout our buffer sizes. - p.recv_status_line() - p.recv_headers() - p.recv_body() + await p.recv_status_line() + await p.recv_headers() + await p.recv_body() msg = 'Not enough data received for declared Content-Length' - with pytest.raises(httpx.ProtocolError, match=msg): - p.recv_body() + with pytest.raises(ahttpx.ProtocolError, match=msg): + await p.recv_body() -def test_parser_not_enough_data_sent(): - stream = httpx.DuplexStream() +@pytest.mark.trio +async def test_parser_not_enough_data_sent(): + stream = ahttpx.DuplexStream() - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"POST", b"/", b"HTTP/1.1") - p.send_headers([ + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"POST", b"/", b"HTTP/1.1") + await p.send_headers([ (b"Host", b"example.com"), (b"Content-Type", b"application/json"), (b"Content-Length", b"23"), ]) - p.send_body(b'{"msg": "too smol"}') + await p.send_body(b'{"msg": "too smol"}') msg = 'Not enough data sent for declared Content-Length' - with pytest.raises(httpx.ProtocolError, match=msg): - p.send_body(b'') + with pytest.raises(ahttpx.ProtocolError, match=msg): + await p.send_body(b'') -def test_parser_too_much_data_sent(): - stream = httpx.DuplexStream() +@pytest.mark.trio +async def test_parser_too_much_data_sent(): + stream = ahttpx.DuplexStream() - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"POST", b"/", b"HTTP/1.1") - p.send_headers([ + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"POST", b"/", b"HTTP/1.1") + await p.send_headers([ (b"Host", b"example.com"), (b"Content-Type", b"application/json"), (b"Content-Length", b"19"), ]) msg = 'Too much data sent for declared Content-Length' - with pytest.raises(httpx.ProtocolError, match=msg): - p.send_body(b'{"msg": "too chonky"}') + with pytest.raises(ahttpx.ProtocolError, match=msg): + await p.send_body(b'{"msg": "too chonky"}') -def test_parser_missing_host_header(): - stream = httpx.DuplexStream() +@pytest.mark.trio +async def test_parser_missing_host_header(): + stream = ahttpx.DuplexStream() - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") msg = "Request missing 'Host' header" - with pytest.raises(httpx.ProtocolError, match=msg): - p.send_headers([]) + with pytest.raises(ahttpx.ProtocolError, match=msg): + await p.send_headers([]) -def test_client_connection_close(): - stream = httpx.DuplexStream( +@pytest.mark.trio +async def test_client_connection_close(): + stream = ahttpx.DuplexStream( b"HTTP/1.1 200 OK\r\n" b"Content-Length: 12\r\n" b"Content-Type: text/plain\r\n" @@ -529,18 +545,18 @@ def test_client_connection_close(): b"hello, world" ) - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") - p.send_headers([ + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") + await p.send_headers([ (b"Host", b"example.com"), (b"Connection", b"close"), ]) - p.send_body(b'') + await p.send_body(b'') - protocol, code, reason_phase = p.recv_status_line() - headers = p.recv_headers() - body = p.recv_body() - terminator = p.recv_body() + protocol, code, reason_phase = await p.recv_status_line() + headers = await p.recv_headers() + body = await p.recv_body() + terminator = await p.recv_body() assert protocol == b'HTTP/1.1' assert code == 200 @@ -554,13 +570,14 @@ def test_client_connection_close(): assert repr(p) == "" - p.complete() + await p.complete() assert repr(p) == "" assert p.is_closed() -def test_server_connection_close(): - stream = httpx.DuplexStream( +@pytest.mark.trio +async def test_server_connection_close(): + stream = ahttpx.DuplexStream( b"HTTP/1.1 200 OK\r\n" b"Content-Length: 12\r\n" b"Content-Type: text/plain\r\n" @@ -569,15 +586,15 @@ def test_server_connection_close(): b"hello, world" ) - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") - p.send_headers([(b"Host", b"example.com")]) - p.send_body(b'') + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") + await p.send_headers([(b"Host", b"example.com")]) + await p.send_body(b'') - protocol, code, reason_phase = p.recv_status_line() - headers = p.recv_headers() - body = p.recv_body() - terminator = p.recv_body() + protocol, code, reason_phase = await p.recv_status_line() + headers = await p.recv_headers() + body = await p.recv_body() + terminator = await p.recv_body() assert protocol == b'HTTP/1.1' assert code == 200 @@ -591,12 +608,13 @@ def test_server_connection_close(): assert terminator == b"" assert repr(p) == "" - p.complete() + await p.complete() assert repr(p) == "" -def test_invalid_status_code(): - stream = httpx.DuplexStream( +@pytest.mark.trio +async def test_invalid_status_code(): + stream = ahttpx.DuplexStream( b"HTTP/1.1 99 OK\r\n" b"Content-Length: 12\r\n" b"Content-Type: text/plain\r\n" @@ -604,21 +622,22 @@ def test_invalid_status_code(): b"hello, world" ) - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") - p.send_headers([ + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") + await p.send_headers([ (b"Host", b"example.com"), (b"Connection", b"close"), ]) - p.send_body(b'') + await p.send_body(b'') msg = "Received invalid status code" - with pytest.raises(httpx.ProtocolError, match=msg): - p.recv_status_line() + with pytest.raises(ahttpx.ProtocolError, match=msg): + await p.recv_status_line() -def test_1xx_status_code(): - stream = httpx.DuplexStream( +@pytest.mark.trio +async def test_1xx_status_code(): + stream = ahttpx.DuplexStream( b"HTTP/1.1 103 Early Hints\r\n" b"Link: ; rel=preload; as=style\r\n" b"Link: ; rel=preload; as=script\r\n" @@ -630,13 +649,13 @@ def test_1xx_status_code(): b"hello, world" ) - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") - p.send_headers([(b"Host", b"example.com")]) - p.send_body(b'') + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") + await p.send_headers([(b"Host", b"example.com")]) + await p.send_body(b'') - protocol, code, reason_phase = p.recv_status_line() - headers = p.recv_headers() + protocol, code, reason_phase = await p.recv_status_line() + headers = await p.recv_headers() assert protocol == b'HTTP/1.1' assert code == 103 @@ -646,10 +665,10 @@ def test_1xx_status_code(): (b'Link', b'; rel=preload; as=script'), ] - protocol, code, reason_phase = p.recv_status_line() - headers = p.recv_headers() - body = p.recv_body() - terminator = p.recv_body() + protocol, code, reason_phase = await p.recv_status_line() + headers = await p.recv_headers() + body = await p.recv_body() + terminator = await p.recv_body() assert protocol == b'HTTP/1.1' assert code == 200 @@ -662,8 +681,9 @@ def test_1xx_status_code(): assert terminator == b"" -def test_received_invalid_content_length(): - stream = httpx.DuplexStream( +@pytest.mark.trio +async def test_received_invalid_content_length(): + stream = ahttpx.DuplexStream( b"HTTP/1.1 200 OK\r\n" b"Content-Length: -999\r\n" b"Content-Type: text/plain\r\n" @@ -671,37 +691,39 @@ def test_received_invalid_content_length(): b"hello, world" ) - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") - p.send_headers([ + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") + await p.send_headers([ (b"Host", b"example.com"), (b"Connection", b"close"), ]) - p.send_body(b'') + await p.send_body(b'') - p.recv_status_line() + await p.recv_status_line() msg = "Received invalid Content-Length" - with pytest.raises(httpx.ProtocolError, match=msg): - p.recv_headers() + with pytest.raises(ahttpx.ProtocolError, match=msg): + await p.recv_headers() -def test_sent_invalid_content_length(): - stream = httpx.DuplexStream() +@pytest.mark.trio +async def test_sent_invalid_content_length(): + stream = ahttpx.DuplexStream() - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") msg = "Sent invalid Content-Length" - with pytest.raises(httpx.ProtocolError, match=msg): + with pytest.raises(ahttpx.ProtocolError, match=msg): # Limited to 20 digits. # 100 million terabytes should be enough for anyone. - p.send_headers([ + await p.send_headers([ (b"Host", b"example.com"), (b"Content-Length", b"100000000000000000000"), ]) -def test_received_invalid_characters_in_chunk_size(): - stream = httpx.DuplexStream( +@pytest.mark.trio +async def test_received_invalid_characters_in_chunk_size(): + stream = ahttpx.DuplexStream( b"HTTP/1.1 200 OK\r\n" b"Transfer-Encoding: chunked\r\n" b"Content-Type: text/plain\r\n" @@ -709,23 +731,24 @@ def test_received_invalid_characters_in_chunk_size(): b"0xFF\r\n..." ) - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") - p.send_headers([ + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") + await p.send_headers([ (b"Host", b"example.com"), (b"Connection", b"close"), ]) - p.send_body(b'') + await p.send_body(b'') - p.recv_status_line() - p.recv_headers() + await p.recv_status_line() + await p.recv_headers() msg = "Received invalid chunk size" - with pytest.raises(httpx.ProtocolError, match=msg): - p.recv_body() + with pytest.raises(ahttpx.ProtocolError, match=msg): + await p.recv_body() -def test_received_oversized_chunk(): - stream = httpx.DuplexStream( +@pytest.mark.trio +async def test_received_oversized_chunk(): + stream = ahttpx.DuplexStream( b"HTTP/1.1 200 OK\r\n" b"Transfer-Encoding: chunked\r\n" b"Content-Type: text/plain\r\n" @@ -733,16 +756,16 @@ def test_received_oversized_chunk(): b"FFFFFFFFFF\r\n..." ) - p = httpx.HTTPParser(stream, mode='CLIENT') - p.send_method_line(b"GET", b"/", b"HTTP/1.1") - p.send_headers([ + p = ahttpx.HTTPParser(stream, mode='CLIENT') + await p.send_method_line(b"GET", b"/", b"HTTP/1.1") + await p.send_headers([ (b"Host", b"example.com"), (b"Connection", b"close"), ]) - p.send_body(b'') + await p.send_body(b'') - p.recv_status_line() - p.recv_headers() + await p.recv_status_line() + await p.recv_headers() msg = "Received invalid chunk size" - with pytest.raises(httpx.ProtocolError, match=msg): - p.recv_body() + with pytest.raises(ahttpx.ProtocolError, match=msg): + await p.recv_body() diff --git a/tests/test_pool.py b/tests/test_pool.py index 04cd024..072da67 100644 --- a/tests/test_pool.py +++ b/tests/test_pool.py @@ -1,36 +1,38 @@ -import httpx +import ahttpx import pytest -def hello_world(request): - content = httpx.Text('Hello, world.') - return httpx.Response(200, content=content) +async def hello_world(request): + content = ahttpx.Text('Hello, world.') + return ahttpx.Response(200, content=content) @pytest.fixture -def server(): - with httpx.serve_http(hello_world) as server: +async def server(): + async with ahttpx.serve_http(hello_world) as server: yield server -def test_connection_pool_request(server): - with httpx.ConnectionPool() as pool: +@pytest.mark.trio +async def test_connection_pool_request(server): + async with ahttpx.ConnectionPool() as pool: assert repr(pool) == "" assert len(pool.connections) == 0 - r = pool.request("GET", server.url) + r = await pool.request("GET", server.url) assert r.status_code == 200 assert repr(pool) == "" assert len(pool.connections) == 1 -def test_connection_pool_connection_close(server): - with httpx.ConnectionPool() as pool: +@pytest.mark.trio +async def test_connection_pool_connection_close(server): + async with ahttpx.ConnectionPool() as pool: assert repr(pool) == "" assert len(pool.connections) == 0 - r = pool.request("GET", server.url, headers={"Connection": "close"}) + r = await pool.request("GET", server.url, headers={"Connection": "close"}) # TODO: Really we want closed connections proactively removed from the pool, assert r.status_code == 200 @@ -38,52 +40,57 @@ def test_connection_pool_connection_close(server): assert len(pool.connections) == 1 -def test_connection_pool_stream(server): - with httpx.ConnectionPool() as pool: +@pytest.mark.trio +async def test_connection_pool_stream(server): + async with ahttpx.ConnectionPool() as pool: assert repr(pool) == "" assert len(pool.connections) == 0 - with pool.stream("GET", server.url) as r: + async with await pool.stream("GET", server.url) as r: assert r.status_code == 200 assert repr(pool) == "" assert len(pool.connections) == 1 - r.read() + await r.read() assert repr(pool) == "" assert len(pool.connections) == 1 -def test_connection_pool_cannot_request_after_closed(server): - with httpx.ConnectionPool() as pool: +@pytest.mark.trio +async def test_connection_pool_cannot_request_after_closed(server): + async with ahttpx.ConnectionPool() as pool: pool with pytest.raises(RuntimeError): - pool.request("GET", server.url) + await pool.request("GET", server.url) -def test_connection_pool_should_have_managed_lifespan(server): - pool = httpx.ConnectionPool() +@pytest.mark.trio +async def test_connection_pool_should_have_managed_lifespan(server): + pool = ahttpx.ConnectionPool() with pytest.warns(UserWarning): del pool -def test_connection_request(server): - with httpx.open_connection(server.url) as conn: +@pytest.mark.trio +async def test_connection_request(server): + async with await ahttpx.open_connection(server.url) as conn: assert repr(conn) == f"" - r = conn.request("GET", "/") + r = await conn.request("GET", "/") assert r.status_code == 200 assert repr(conn) == f"" -def test_connection_stream(server): - with httpx.open_connection(server.url) as conn: +@pytest.mark.trio +async def test_connection_stream(server): + async with await ahttpx.open_connection(server.url) as conn: assert repr(conn) == f"" - with conn.stream("GET", "/") as r: + async with await conn.stream("GET", "/") as r: assert r.status_code == 200 assert repr(conn) == f"" - r.read() + await r.read() assert repr(conn) == f"" diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py index 55c34b1..ef3963c 100644 --- a/tests/test_quickstart.py +++ b/tests/test_quickstart.py @@ -1,11 +1,11 @@ import json -import httpx +import ahttpx import pytest -def echo(request): - request.read() - response = httpx.Response(200, content=httpx.JSON({ +async def echo(request): + await request.read() + response = ahttpx.Response(200, content=ahttpx.JSON({ 'method': request.method, 'query-params': dict(request.url.params.items()), 'content-type': request.headers.get('Content-Type'), @@ -15,13 +15,14 @@ def echo(request): @pytest.fixture -def server(): - with httpx.serve_http(echo) as server: +async def server(): + async with ahttpx.serve_http(echo) as server: yield server -def test_get(server): - r = httpx.get(server.url) +@pytest.mark.trio +async def test_get(server): + r = await ahttpx.get(server.url) assert r.status_code == 200 assert json.loads(r.body) == { 'method': 'GET', @@ -31,9 +32,10 @@ def test_get(server): } -def test_post(server): - data = httpx.JSON({"data": 123}) - r = httpx.post(server.url, content=data) +@pytest.mark.trio +async def test_post(server): + data = ahttpx.JSON({"data": 123}) + r = await ahttpx.post(server.url, content=data) assert r.status_code == 200 assert json.loads(r.body) == { 'method': 'POST', @@ -43,9 +45,10 @@ def test_post(server): } -def test_put(server): - data = httpx.JSON({"data": 123}) - r = httpx.put(server.url, content=data) +@pytest.mark.trio +async def test_put(server): + data = ahttpx.JSON({"data": 123}) + r = await ahttpx.put(server.url, content=data) assert r.status_code == 200 assert json.loads(r.body) == { 'method': 'PUT', @@ -55,9 +58,10 @@ def test_put(server): } -def test_patch(server): - data = httpx.JSON({"data": 123}) - r = httpx.patch(server.url, content=data) +@pytest.mark.trio +async def test_patch(server): + data = ahttpx.JSON({"data": 123}) + r = await ahttpx.patch(server.url, content=data) assert r.status_code == 200 assert json.loads(r.body) == { 'method': 'PATCH', @@ -67,8 +71,9 @@ def test_patch(server): } -def test_delete(server): - r = httpx.delete(server.url) +@pytest.mark.trio +async def test_delete(server): + r = await ahttpx.delete(server.url) assert r.status_code == 200 assert json.loads(r.body) == { 'method': 'DELETE', diff --git a/tests/test_request.py b/tests/test_request.py index a69e1d1..6bc9f3d 100644 --- a/tests/test_request.py +++ b/tests/test_request.py @@ -1,18 +1,20 @@ -import httpx +import ahttpx +import pytest class ByteIterator: def __init__(self, buffer=b""): self._buffer = buffer - - def next(self) -> bytes: + + async def next(self) -> bytes: buffer = self._buffer self._buffer = b'' return buffer -def test_request(): - r = httpx.Request("GET", "https://example.com") +@pytest.mark.trio +async def test_request(): + r = ahttpx.Request("GET", "https://example.com") assert repr(r) == "" assert r.method == "GET" @@ -20,11 +22,12 @@ def test_request(): assert r.headers == { "Host": "example.com" } - assert r.read() == b"" + assert await r.read() == b"" -def test_request_bytes(): +@pytest.mark.trio +async def test_request_bytes(): content = b"Hello, world" - r = httpx.Request("POST", "https://example.com", content=content) + r = ahttpx.Request("POST", "https://example.com", content=content) assert repr(r) == "" assert r.method == "POST" @@ -33,13 +36,14 @@ def test_request_bytes(): "Host": "example.com", "Content-Length": "12", } - assert r.read() == b"Hello, world" + assert await r.read() == b"Hello, world" -def test_request_stream(): +@pytest.mark.trio +async def test_request_stream(): i = ByteIterator(b"Hello, world") - stream = httpx.HTTPStream(i.next, None) - r = httpx.Request("POST", "https://example.com", content=stream) + stream = ahttpx.HTTPStream(i.next, None) + r = ahttpx.Request("POST", "https://example.com", content=stream) assert repr(r) == "" assert r.method == "POST" @@ -48,12 +52,13 @@ def test_request_stream(): "Host": "example.com", "Transfer-Encoding": "chunked", } - assert r.read() == b"Hello, world" + assert await r.read() == b"Hello, world" -def test_request_json(): - data = httpx.JSON({"msg": "Hello, world"}) - r = httpx.Request("POST", "https://example.com", content=data) +@pytest.mark.trio +async def test_request_json(): + data = ahttpx.JSON({"msg": "Hello, world"}) + r = ahttpx.Request("POST", "https://example.com", content=data) assert repr(r) == "" assert r.method == "POST" @@ -63,11 +68,12 @@ def test_request_json(): "Content-Length": "22", "Content-Type": "application/json", } - assert r.read() == b'{"msg":"Hello, world"}' + assert await r.read() == b'{"msg":"Hello, world"}' -def test_request_empty_post(): - r = httpx.Request("POST", "https://example.com") +@pytest.mark.trio +async def test_request_empty_post(): + r = ahttpx.Request("POST", "https://example.com") assert repr(r) == "" assert r.method == "POST" @@ -76,4 +82,4 @@ def test_request_empty_post(): "Host": "example.com", "Content-Length": "0", } - assert r.read() == b'' + assert await r.read() == b'' diff --git a/tests/test_response.py b/tests/test_response.py index d25ebeb..3b2f4bf 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -1,64 +1,70 @@ -import httpx +import ahttpx +import pytest class ByteIterator: def __init__(self, buffer=b""): self._buffer = buffer - def next(self) -> bytes: + async def next(self) -> bytes: buffer = self._buffer self._buffer = b'' return buffer -def test_response(): - r = httpx.Response(200) +@pytest.mark.trio +async def test_response(): + r = ahttpx.Response(200) assert repr(r) == "" assert r.status_code == 200 assert r.headers == {'Content-Length': '0'} - assert r.read() == b"" + assert await r.read() == b"" -def test_response_204(): - r = httpx.Response(204) +@pytest.mark.trio +async def test_response_204(): + r = ahttpx.Response(204) assert repr(r) == "" assert r.status_code == 204 assert r.headers == {} - assert r.read() == b"" + assert await r.read() == b"" -def test_response_bytes(): +@pytest.mark.trio +async def test_response_bytes(): content = b"Hello, world" - r = httpx.Response(200, content=content) + r = ahttpx.Response(200, content=content) assert repr(r) == "" assert r.headers == { "Content-Length": "12", } - assert r.read() == b"Hello, world" + assert await r.read() == b"Hello, world" -def test_response_stream(): +@pytest.mark.trio +async def test_response_stream(): i = ByteIterator(b"Hello, world") - stream = httpx.HTTPStream(i.next, None) - r = httpx.Response(200, content=stream) + stream = ahttpx.HTTPStream(i.next, None) + r = ahttpx.Response(200, content=stream) assert repr(r) == "" assert r.headers == { "Transfer-Encoding": "chunked", } - assert r.read() == b"Hello, world" + assert await r.read() == b"Hello, world" -def test_response_json(): - data = httpx.JSON({"msg": "Hello, world"}) - r = httpx.Response(200, content=data) +@pytest.mark.trio +async def test_response_json(): + data = ahttpx.JSON({"msg": "Hello, world"}) + r = ahttpx.Response(200, content=data) assert repr(r) == "" assert r.headers == { "Content-Length": "22", "Content-Type": "application/json", } - assert r.read() == b'{"msg":"Hello, world"}' + assert await r.read() == b'{"msg":"Hello, world"}' diff --git a/tests/test_streams.py b/tests/test_streams.py index 8053761..b898ac5 100644 --- a/tests/test_streams.py +++ b/tests/test_streams.py @@ -1,51 +1,54 @@ import pytest -import httpx +import ahttpx -def test_stream(): - i = httpx.Stream() +@pytest.mark.trio +async def test_stream(): + i = ahttpx.Stream() with pytest.raises(NotImplementedError): - i.read() + await i.read() with pytest.raises(NotImplementedError): - i.close() + await i.close() i.size == None -def test_bytestream(): +@pytest.mark.trio +async def test_bytestream(): data = b'abc' - s = httpx.ByteStream(data) + s = ahttpx.ByteStream(data) assert s.size == 3 - assert s.read() == b'abc' + assert await s.read() == b'abc' - s = httpx.ByteStream(data) - assert s.read(1) == b'a' - assert s.read(1) == b'b' - assert s.read(1) == b'c' - assert s.read(1) == b'' + s = ahttpx.ByteStream(data) + assert await s.read(1) == b'a' + assert await s.read(1) == b'b' + assert await s.read(1) == b'c' + assert await s.read(1) == b'' -def test_filestream(tmp_path): +@pytest.mark.trio +async def test_filestream(tmp_path): path = tmp_path / "example.txt" path.write_bytes(b"hello world") - with httpx.FileStream(path) as s: + async with ahttpx.FileStream(path) as s: assert s.size == 11 - assert s.read() == b'hello world' + assert await s.read() == b'hello world' - with httpx.FileStream(path) as s: - assert s.read(5) == b'hello' - assert s.read(5) == b' worl' - assert s.read(5) == b'd' - assert s.read(5) == b'' + async with ahttpx.FileStream(path) as s: + assert await s.read(5) == b'hello' + assert await s.read(5) == b' worl' + assert await s.read(5) == b'd' + assert await s.read(5) == b'' - with httpx.FileStream(path) as s: - assert s.read(5) == b'hello' + async with ahttpx.FileStream(path) as s: + assert await s.read(5) == b'hello' - -def test_multipartstream(tmp_path): +@pytest.mark.trio +async def test_multipartstream(tmp_path): path = tmp_path / 'example.txt' path.write_bytes(b'hello world' + b'x' * 50) @@ -63,20 +66,20 @@ def test_multipartstream(tmp_path): form = [('email', 'heya@example.com')] files = [('upload', str(path))] - with httpx.MultiPartStream(form, files, boundary='boundary') as s: + async with ahttpx.MultiPartStream(form, files, boundary='boundary') as s: assert s.size is None - assert s.read() == expected - - with httpx.MultiPartStream(form, files, boundary='boundary') as s: - assert s.read(50) == expected[:50] - assert s.read(50) == expected[50:100] - assert s.read(50) == expected[100:150] - assert s.read(50) == expected[150:200] - assert s.read(50) == expected[200:250] - - with httpx.MultiPartStream(form, files, boundary='boundary') as s: - assert s.read(50) == expected[:50] - assert s.read(50) == expected[50:100] - assert s.read(50) == expected[100:150] - assert s.read(50) == expected[150:200] - s.close() # test close during open file + assert await s.read() == expected + + async with ahttpx.MultiPartStream(form, files, boundary='boundary') as s: + assert await s.read(50) == expected[:50] + assert await s.read(50) == expected[50:100] + assert await s.read(50) == expected[100:150] + assert await s.read(50) == expected[150:200] + assert await s.read(50) == expected[200:250] + + async with ahttpx.MultiPartStream(form, files, boundary='boundary') as s: + assert await s.read(50) == expected[:50] + assert await s.read(50) == expected[50:100] + assert await s.read(50) == expected[100:150] + assert await s.read(50) == expected[150:200] + await s.close() # test close during open file diff --git a/tests/test_urlencode.py b/tests/test_urlencode.py index 42ba45a..1c6afbd 100644 --- a/tests/test_urlencode.py +++ b/tests/test_urlencode.py @@ -1,9 +1,9 @@ -import httpx +import ahttpx def test_urlencode(): qs = "a=name%40example.com&a=456&b=7+8+9&c" - d = httpx.urldecode(qs) + d = ahttpx.urldecode(qs) assert d == { "a": ["name@example.com", "456"], "b": ["7 8 9"], @@ -17,17 +17,17 @@ def test_urldecode(): "b": ["7 8 9"], "c": [""] } - qs = httpx.urlencode(d) + qs = ahttpx.urlencode(d) assert qs == "a=name%40example.com&a=456&b=7+8+9&c=" def test_urlencode_empty(): qs = "" - d = httpx.urldecode(qs) + d = ahttpx.urldecode(qs) assert d == {} def test_urldecode_empty(): d = {} - qs = httpx.urlencode(d) + qs = ahttpx.urlencode(d) assert qs == "" diff --git a/tests/test_urls.py b/tests/test_urls.py index ad72935..354ec3c 100644 --- a/tests/test_urls.py +++ b/tests/test_urls.py @@ -1,117 +1,117 @@ -import httpx +import ahttpx import pytest def test_url(): - url = httpx.URL('https://www.example.com/') + url = ahttpx.URL('https://www.example.com/') assert str(url) == "https://www.example.com/" def test_url_repr(): - url = httpx.URL('https://www.example.com/') + url = ahttpx.URL('https://www.example.com/') assert repr(url) == "" def test_url_params(): - url = httpx.URL('https://www.example.com/', params={"a": "b", "c": "d"}) + url = ahttpx.URL('https://www.example.com/', params={"a": "b", "c": "d"}) assert str(url) == "https://www.example.com/?a=b&c=d" def test_url_normalisation(): - url = httpx.URL('https://www.EXAMPLE.com:443/path/../main') + url = ahttpx.URL('https://www.EXAMPLE.com:443/path/../main') assert str(url) == 'https://www.example.com/main' def test_url_relative(): - url = httpx.URL('/README.md') + url = ahttpx.URL('/README.md') assert str(url) == '/README.md' def test_url_escaping(): - url = httpx.URL('https://example.com/path to here?search=🦋') + url = ahttpx.URL('https://example.com/path to here?search=🦋') assert str(url) == 'https://example.com/path%20to%20here?search=%F0%9F%A6%8B' def test_url_components(): - url = httpx.URL(scheme="https", host="example.com", path="/") + url = ahttpx.URL(scheme="https", host="example.com", path="/") assert str(url) == 'https://example.com/' # QueryParams def test_queryparams(): - params = httpx.QueryParams({"color": "black", "size": "medium"}) + params = ahttpx.QueryParams({"color": "black", "size": "medium"}) assert str(params) == 'color=black&size=medium' def test_queryparams_repr(): - params = httpx.QueryParams({"color": "black", "size": "medium"}) + params = ahttpx.QueryParams({"color": "black", "size": "medium"}) assert repr(params) == "" def test_queryparams_list_of_values(): - params = httpx.QueryParams({"filter": ["60GHz", "75GHz", "100GHz"]}) + params = ahttpx.QueryParams({"filter": ["60GHz", "75GHz", "100GHz"]}) assert str(params) == 'filter=60GHz&filter=75GHz&filter=100GHz' def test_queryparams_from_str(): - params = httpx.QueryParams("color=black&size=medium") + params = ahttpx.QueryParams("color=black&size=medium") assert str(params) == 'color=black&size=medium' def test_queryparams_access(): - params = httpx.QueryParams("sort_by=published&author=natalie") + params = ahttpx.QueryParams("sort_by=published&author=natalie") assert params["sort_by"] == 'published' def test_queryparams_escaping(): - params = httpx.QueryParams({"email": "user@example.com", "search": "How HTTP works!"}) + params = ahttpx.QueryParams({"email": "user@example.com", "search": "How HTTP works!"}) assert str(params) == 'email=user%40example.com&search=How+HTTP+works%21' def test_queryparams_empty(): - q = httpx.QueryParams({"a": ""}) + q = ahttpx.QueryParams({"a": ""}) assert str(q) == "a=" - q = httpx.QueryParams("a=") + q = ahttpx.QueryParams("a=") assert str(q) == "a=" - q = httpx.QueryParams("a") + q = ahttpx.QueryParams("a") assert str(q) == "a=" def test_queryparams_set(): - q = httpx.QueryParams("a=123") + q = ahttpx.QueryParams("a=123") q = q.copy_set("a", "456") - assert q == httpx.QueryParams("a=456") + assert q == ahttpx.QueryParams("a=456") def test_queryparams_append(): - q = httpx.QueryParams("a=123") + q = ahttpx.QueryParams("a=123") q = q.copy_append("a", "456") - assert q == httpx.QueryParams("a=123&a=456") + assert q == ahttpx.QueryParams("a=123&a=456") def test_queryparams_remove(): - q = httpx.QueryParams("a=123") + q = ahttpx.QueryParams("a=123") q = q.copy_remove("a") - assert q == httpx.QueryParams("") + assert q == ahttpx.QueryParams("") def test_queryparams_merge(): - q = httpx.QueryParams("a=123") + q = ahttpx.QueryParams("a=123") q = q.copy_update({"b": "456"}) - assert q == httpx.QueryParams("a=123&b=456") + assert q == ahttpx.QueryParams("a=123&b=456") q = q.copy_update({"a": "000", "c": "789"}) - assert q == httpx.QueryParams("a=000&b=456&c=789") + assert q == ahttpx.QueryParams("a=000&b=456&c=789") def test_queryparams_are_hashable(): params = ( - httpx.QueryParams("a=123"), - httpx.QueryParams({"a": "123"}), - httpx.QueryParams("b=456"), - httpx.QueryParams({"b": "456"}), + ahttpx.QueryParams("a=123"), + ahttpx.QueryParams({"a": "123"}), + ahttpx.QueryParams("b=456"), + ahttpx.QueryParams({"b": "456"}), ) assert len(set(params)) == 2 @@ -128,7 +128,7 @@ def test_queryparams_are_hashable(): ], ) def test_queryparams_misc(source): - q = httpx.QueryParams(source) + q = ahttpx.QueryParams(source) assert "a" in q assert "A" not in q assert "c" not in q @@ -145,20 +145,20 @@ def test_queryparams_misc(source): assert list(q) == ["a", "b"] assert dict(q) == {"a": "123", "b": "789"} assert str(q) == "a=123&a=456&b=789" - assert httpx.QueryParams({"a": "123", "b": "456"}) == httpx.QueryParams( + assert ahttpx.QueryParams({"a": "123", "b": "456"}) == ahttpx.QueryParams( [("a", "123"), ("b", "456")] ) - assert httpx.QueryParams({"a": "123", "b": "456"}) == httpx.QueryParams( + assert ahttpx.QueryParams({"a": "123", "b": "456"}) == ahttpx.QueryParams( "a=123&b=456" ) - assert httpx.QueryParams({"a": "123", "b": "456"}) == httpx.QueryParams( + assert ahttpx.QueryParams({"a": "123", "b": "456"}) == ahttpx.QueryParams( {"b": "456", "a": "123"} ) - assert httpx.QueryParams() == httpx.QueryParams({}) - assert httpx.QueryParams([("a", "123"), ("a", "456")]) == httpx.QueryParams( + assert ahttpx.QueryParams() == ahttpx.QueryParams({}) + assert ahttpx.QueryParams([("a", "123"), ("a", "456")]) == ahttpx.QueryParams( "a=123&a=456" ) - assert httpx.QueryParams({"a": "123", "b": "456"}) != "invalid" + assert ahttpx.QueryParams({"a": "123", "b": "456"}) != "invalid" - q = httpx.QueryParams([("a", "123"), ("a", "456")]) - assert httpx.QueryParams(q) == q + q = ahttpx.QueryParams([("a", "123"), ("a", "456")]) + assert ahttpx.QueryParams(q) == q From 9c26eb38c108ba8791e56ceba25aab6113be0a43 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 25 Feb 2026 17:04:48 +0000 Subject: [PATCH 03/15] Update requirements --- requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/requirements.txt b/requirements.txt index 8fd226d..c39d7d8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ -e . +trio==0.33.0 + # Build... build==1.2.2 From a6187835e8c62a89a9cba9178aabb9102f6f65df Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 26 Feb 2026 12:19:10 +0000 Subject: [PATCH 04/15] Clean server stream closes --- src/ahttpx/_network.py | 11 ++++++++--- tests/test_client.py | 5 ----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/ahttpx/_network.py b/src/ahttpx/_network.py index da3cd8a..63d7c8f 100644 --- a/src/ahttpx/_network.py +++ b/src/ahttpx/_network.py @@ -30,8 +30,10 @@ async def write(self, buffer: bytes) -> None: async def close(self) -> None: # Close the NetworkStream. # If the stream is already closed this is a checkpointed no-op. - await self._trio_stream.aclose() - self._closed = True + try: + await self._trio_stream.aclose() + finally: + self._closed = True def __repr__(self): description = "" @@ -115,7 +117,10 @@ async def connect_tls(self, host: str, port: int, hostname: str = '') -> Network async def serve(self, host: str, port: int, handler: typing.Callable[[NetworkStream], None]) -> NetworkServer: async def callback(trio_stream): stream = NetworkStream(trio_stream, address=f"{host}:{port}") - await handler(stream) + try: + await handler(stream) + finally: + await stream.close() listeners = await trio.open_tcp_listeners(port=port, host=host) return NetworkServer(host, port, callback, listeners) diff --git a/tests/test_client.py b/tests/test_client.py index ad1c23c..f7be2c2 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -33,11 +33,6 @@ async def test_client(client): @pytest.mark.trio async def test_get(): - async with ahttpx.serve_http(echo) as server: - async with ahttpx.Client() as client: - r = await client.get(server.url) - - return async with ahttpx.serve_http(echo) as server: async with ahttpx.Client() as client: r = await client.get(server.url) From 4079f3d1fc349006b3ad5c524aba2ad4cf00a367 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 26 Feb 2026 13:05:25 +0000 Subject: [PATCH 05/15] Update requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index c39d7d8..f441484 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,6 +9,7 @@ build==1.2.2 mypy==1.15.0 pytest==8.3.5 pytest-cov==6.1.1 +pytest-trio==0.8.0 # Sync & Async mirroring... unasync==0.6.0 From 5465f7d8253fe12a4365872a1b7df46399c145d8 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 26 Feb 2026 14:58:22 +0000 Subject: [PATCH 06/15] NetworkClose is handled in the stream --- src/ahttpx/_server.py | 1 - src/httpx/_server.py | 1 - 2 files changed, 2 deletions(-) diff --git a/src/ahttpx/_server.py b/src/ahttpx/_server.py index 6591e31..de9179a 100644 --- a/src/ahttpx/_server.py +++ b/src/ahttpx/_server.py @@ -50,7 +50,6 @@ async def handle_requests(self): await self._send_head(response) await self._send_body(response) except BaseException: - await self._stream.close() logger.error("Internal Server Error", exc_info=True) async def close(self): diff --git a/src/httpx/_server.py b/src/httpx/_server.py index 81efe58..14d23fa 100644 --- a/src/httpx/_server.py +++ b/src/httpx/_server.py @@ -50,7 +50,6 @@ def handle_requests(self): self._send_head(response) self._send_body(response) except BaseException: - self._stream.close() logger.error("Internal Server Error", exc_info=True) def close(self): From 118396af1ba0a6e6ec9649be4a0a5c6aaa20041c Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 26 Feb 2026 17:10:28 +0000 Subject: [PATCH 07/15] Stream closing for sync client --- src/httpx/_network.py | 6 +++++- src/httpx/_parsers.py | 1 + tests/{ => test_ahttpx}/__init__.py | 0 tests/{ => test_ahttpx}/test_client.py | 0 tests/{ => test_ahttpx}/test_content.py | 0 tests/{ => test_ahttpx}/test_headers.py | 0 tests/{ => test_ahttpx}/test_network.py | 0 tests/{ => test_ahttpx}/test_parsers.py | 0 tests/{ => test_ahttpx}/test_pool.py | 0 tests/{ => test_ahttpx}/test_quickstart.py | 0 tests/{ => test_ahttpx}/test_request.py | 0 tests/{ => test_ahttpx}/test_response.py | 0 tests/{ => test_ahttpx}/test_streams.py | 0 tests/{ => test_ahttpx}/test_urlencode.py | 0 tests/{ => test_ahttpx}/test_urls.py | 0 15 files changed, 6 insertions(+), 1 deletion(-) rename tests/{ => test_ahttpx}/__init__.py (100%) rename tests/{ => test_ahttpx}/test_client.py (100%) rename tests/{ => test_ahttpx}/test_content.py (100%) rename tests/{ => test_ahttpx}/test_headers.py (100%) rename tests/{ => test_ahttpx}/test_network.py (100%) rename tests/{ => test_ahttpx}/test_parsers.py (100%) rename tests/{ => test_ahttpx}/test_pool.py (100%) rename tests/{ => test_ahttpx}/test_quickstart.py (100%) rename tests/{ => test_ahttpx}/test_request.py (100%) rename tests/{ => test_ahttpx}/test_response.py (100%) rename tests/{ => test_ahttpx}/test_streams.py (100%) rename tests/{ => test_ahttpx}/test_urlencode.py (100%) rename tests/{ => test_ahttpx}/test_urls.py (100%) diff --git a/src/httpx/_network.py b/src/httpx/_network.py index 5ea9bb5..8410f61 100644 --- a/src/httpx/_network.py +++ b/src/httpx/_network.py @@ -160,7 +160,7 @@ def __init__(self, listener: NetworkListener, handler: typing.Callable[[NetworkS self._max_workers = 5 self._executor = None self._thread = None - self._streams = list[NetworkStream] + self._streams: list[NetworkStream] = [] @property def host(self): @@ -176,6 +176,8 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): + for stream in self._streams: + stream.close() self.listener.close() self._executor.shutdown(wait=True) @@ -185,9 +187,11 @@ def _serve(self): def _handler(self, stream): try: + self._streams.append(stream) self.handler(stream) finally: stream.close() + self._streams.remove(stream) class NetworkBackend: diff --git a/src/httpx/_parsers.py b/src/httpx/_parsers.py index b8be024..abd0d49 100644 --- a/src/httpx/_parsers.py +++ b/src/httpx/_parsers.py @@ -232,6 +232,7 @@ def recv_close(self) -> bool: if self.parser.read_eof(): self.close() return True + return False def recv_method_line(self) -> tuple[bytes, bytes, bytes]: diff --git a/tests/__init__.py b/tests/test_ahttpx/__init__.py similarity index 100% rename from tests/__init__.py rename to tests/test_ahttpx/__init__.py diff --git a/tests/test_client.py b/tests/test_ahttpx/test_client.py similarity index 100% rename from tests/test_client.py rename to tests/test_ahttpx/test_client.py diff --git a/tests/test_content.py b/tests/test_ahttpx/test_content.py similarity index 100% rename from tests/test_content.py rename to tests/test_ahttpx/test_content.py diff --git a/tests/test_headers.py b/tests/test_ahttpx/test_headers.py similarity index 100% rename from tests/test_headers.py rename to tests/test_ahttpx/test_headers.py diff --git a/tests/test_network.py b/tests/test_ahttpx/test_network.py similarity index 100% rename from tests/test_network.py rename to tests/test_ahttpx/test_network.py diff --git a/tests/test_parsers.py b/tests/test_ahttpx/test_parsers.py similarity index 100% rename from tests/test_parsers.py rename to tests/test_ahttpx/test_parsers.py diff --git a/tests/test_pool.py b/tests/test_ahttpx/test_pool.py similarity index 100% rename from tests/test_pool.py rename to tests/test_ahttpx/test_pool.py diff --git a/tests/test_quickstart.py b/tests/test_ahttpx/test_quickstart.py similarity index 100% rename from tests/test_quickstart.py rename to tests/test_ahttpx/test_quickstart.py diff --git a/tests/test_request.py b/tests/test_ahttpx/test_request.py similarity index 100% rename from tests/test_request.py rename to tests/test_ahttpx/test_request.py diff --git a/tests/test_response.py b/tests/test_ahttpx/test_response.py similarity index 100% rename from tests/test_response.py rename to tests/test_ahttpx/test_response.py diff --git a/tests/test_streams.py b/tests/test_ahttpx/test_streams.py similarity index 100% rename from tests/test_streams.py rename to tests/test_ahttpx/test_streams.py diff --git a/tests/test_urlencode.py b/tests/test_ahttpx/test_urlencode.py similarity index 100% rename from tests/test_urlencode.py rename to tests/test_ahttpx/test_urlencode.py diff --git a/tests/test_urls.py b/tests/test_ahttpx/test_urls.py similarity index 100% rename from tests/test_urls.py rename to tests/test_ahttpx/test_urls.py From d80eab966443b11db6eaeadf5e0a9293e5aa57d7 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 26 Feb 2026 17:27:02 +0000 Subject: [PATCH 08/15] Tests for sync/async variants --- scripts/unasync | 48 ++ src/httpx/_parsers.py | 1 - tests/test_ahttpx/test_network.py | 2 +- tests/test_httpx/test_client.py | 114 +++++ tests/test_httpx/test_content.py | 285 +++++++++++ tests/test_httpx/test_headers.py | 109 ++++ tests/test_httpx/test_network.py | 101 ++++ tests/test_httpx/test_parsers.py | 747 ++++++++++++++++++++++++++++ tests/test_httpx/test_pool.py | 126 +++++ tests/test_httpx/test_quickstart.py | 78 +++ tests/test_httpx/test_request.py | 80 +++ tests/test_httpx/test_response.py | 65 +++ tests/test_httpx/test_streams.py | 81 +++ tests/test_httpx/test_urlencode.py | 33 ++ tests/test_httpx/test_urls.py | 164 ++++++ 15 files changed, 2032 insertions(+), 2 deletions(-) create mode 100644 tests/test_httpx/test_client.py create mode 100644 tests/test_httpx/test_content.py create mode 100644 tests/test_httpx/test_headers.py create mode 100644 tests/test_httpx/test_network.py create mode 100644 tests/test_httpx/test_parsers.py create mode 100644 tests/test_httpx/test_pool.py create mode 100644 tests/test_httpx/test_quickstart.py create mode 100644 tests/test_httpx/test_request.py create mode 100644 tests/test_httpx/test_response.py create mode 100644 tests/test_httpx/test_streams.py create mode 100644 tests/test_httpx/test_urlencode.py create mode 100644 tests/test_httpx/test_urls.py diff --git a/scripts/unasync b/scripts/unasync index 67d66b5..b0aca05 100755 --- a/scripts/unasync +++ b/scripts/unasync @@ -27,3 +27,51 @@ unasync.unasync_files( ), ] ) + + +unasync.unasync_files( + fpath_list = [ + "tests/test_ahttpx/test_client.py", + "tests/test_ahttpx/test_content.py", + "tests/test_ahttpx/test_headers.py", + "tests/test_ahttpx/test_network.py", + "tests/test_ahttpx/test_parsers.py", + "tests/test_ahttpx/test_pool.py", + "tests/test_ahttpx/test_quickstart.py", + "tests/test_ahttpx/test_request.py", + "tests/test_ahttpx/test_response.py", + "tests/test_ahttpx/test_streams.py", + "tests/test_ahttpx/test_urlencode.py", + "tests/test_ahttpx/test_urls.py", + ], + rules = [ + unasync.Rule( + "tests/test_ahttpx/", + "tests/test_httpx/", + additional_replacements={"ahttpx": "httpx"} + ), + ] +) + + +for path in [ + "tests/test_httpx/test_client.py", + "tests/test_httpx/test_content.py", + "tests/test_httpx/test_headers.py", + "tests/test_httpx/test_network.py", + "tests/test_httpx/test_parsers.py", + "tests/test_httpx/test_pool.py", + "tests/test_httpx/test_quickstart.py", + "tests/test_httpx/test_request.py", + "tests/test_httpx/test_response.py", + "tests/test_httpx/test_streams.py", + "tests/test_httpx/test_urlencode.py", + "tests/test_httpx/test_urls.py", +]: + with open(path, "r") as fin: + lines = fin.readlines() + + lines = [line for line in lines if line != "@pytest.mark.trio\n"] + + with open(path, "w") as fout: + fout.writelines(lines) \ No newline at end of file diff --git a/src/httpx/_parsers.py b/src/httpx/_parsers.py index abd0d49..b8be024 100644 --- a/src/httpx/_parsers.py +++ b/src/httpx/_parsers.py @@ -232,7 +232,6 @@ def recv_close(self) -> bool: if self.parser.read_eof(): self.close() return True - return False def recv_method_line(self) -> tuple[bytes, bytes, bytes]: diff --git a/tests/test_ahttpx/test_network.py b/tests/test_ahttpx/test_network.py index 0f27de5..9c34ac8 100644 --- a/tests/test_ahttpx/test_network.py +++ b/tests/test_ahttpx/test_network.py @@ -16,7 +16,7 @@ async def server(): def test_network_backend(): net = ahttpx.NetworkBackend() - assert repr(net) == "" + assert repr(net) in ["", ""] @pytest.mark.trio diff --git a/tests/test_httpx/test_client.py b/tests/test_httpx/test_client.py new file mode 100644 index 0000000..6aa76f5 --- /dev/null +++ b/tests/test_httpx/test_client.py @@ -0,0 +1,114 @@ +import json +import httpx +import pytest + + +def echo(request): + request.read() + response = httpx.Response(200, content=httpx.JSON({ + 'method': request.method, + 'query-params': dict(request.url.params.items()), + 'content-type': request.headers.get('Content-Type'), + 'json': json.loads(request.body) if request.body else None, + })) + return response + + +@pytest.fixture +def client(): + with httpx.Client() as client: + yield client + + +@pytest.fixture +def server(): + with httpx.serve_http(echo) as server: + yield server + + +def test_client(client): + assert repr(client) == "" + + +def test_get(): + with httpx.serve_http(echo) as server: + with httpx.Client() as client: + r = client.get(server.url) + assert r.status_code == 200 + assert r.body == b'{"method":"GET","query-params":{},"content-type":null,"json":null}' + assert r.text == '{"method":"GET","query-params":{},"content-type":null,"json":null}' + + +def test_post(client, server): + data = httpx.JSON({"data": 123}) + r = client.post(server.url, content=data) + assert r.status_code == 200 + assert json.loads(r.body) == { + 'method': 'POST', + 'query-params': {}, + 'content-type': 'application/json', + 'json': {"data": 123}, + } + + +def test_put(client, server): + data = httpx.JSON({"data": 123}) + r = client.put(server.url, content=data) + assert r.status_code == 200 + assert json.loads(r.body) == { + 'method': 'PUT', + 'query-params': {}, + 'content-type': 'application/json', + 'json': {"data": 123}, + } + + +def test_patch(client, server): + data = httpx.JSON({"data": 123}) + r = client.patch(server.url, content=data) + assert r.status_code == 200 + assert json.loads(r.body) == { + 'method': 'PATCH', + 'query-params': {}, + 'content-type': 'application/json', + 'json': {"data": 123}, + } + + +def test_delete(client, server): + r = client.delete(server.url) + assert r.status_code == 200 + assert json.loads(r.body) == { + 'method': 'DELETE', + 'query-params': {}, + 'content-type': None, + 'json': None, + } + + +def test_request(client, server): + r = client.request("GET", server.url) + assert r.status_code == 200 + assert json.loads(r.body) == { + 'method': 'GET', + 'query-params': {}, + 'content-type': None, + 'json': None, + } + + +def test_stream(client, server): + with client.stream("GET", server.url) as r: + assert r.status_code == 200 + r.read() + assert json.loads(r.body) == { + 'method': 'GET', + 'query-params': {}, + 'content-type': None, + 'json': None, + } + + +def test_get_with_invalid_scheme(client): + with pytest.raises(ValueError): + client.get("nope://www.example.com") diff --git a/tests/test_httpx/test_content.py b/tests/test_httpx/test_content.py new file mode 100644 index 0000000..d5fc3de --- /dev/null +++ b/tests/test_httpx/test_content.py @@ -0,0 +1,285 @@ +import httpx +import os +import tempfile +import pytest + + +# HTML + +def test_html(): + html = httpx.HTML("Hello, world") + + stream = html.encode() + content_type = html.content_type() + + assert stream.read() == b'Hello, world' + assert content_type == "text/html; charset='utf-8'" + + +# Text + +def test_text(): + text = httpx.Text("Hello, world") + + stream = text.encode() + content_type = text.content_type() + + assert stream.read() == b'Hello, world' + assert content_type == "text/plain; charset='utf-8'" + + +# JSON + +def test_json(): + data = httpx.JSON({'data': 123}) + + stream = data.encode() + content_type = data.content_type() + + assert stream.read() == b'{"data":123}' + assert content_type == "application/json" + + +# Form + +def test_form(): + f = httpx.Form("a=123&a=456&b=789") + assert str(f) == "a=123&a=456&b=789" + assert repr(f) == "" + assert f.multi_dict() == { + "a": ["123", "456"], + "b": ["789"] + } + + +def test_form_from_dict(): + f = httpx.Form({ + "a": ["123", "456"], + "b": "789" + }) + assert str(f) == "a=123&a=456&b=789" + assert repr(f) == "" + assert f.multi_dict() == { + "a": ["123", "456"], + "b": ["789"] + } + + +def test_form_from_list(): + f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) + assert str(f) == "a=123&a=456&b=789" + assert repr(f) == "" + assert f.multi_dict() == { + "a": ["123", "456"], + "b": ["789"] + } + + +def test_empty_form(): + f = httpx.Form() + assert str(f) == '' + assert repr(f) == "" + assert f.multi_dict() == {} + + +def test_form_accessors(): + f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) + assert "a" in f + assert "A" not in f + assert "c" not in f + assert f["a"] == "123" + assert f.get("a") == "123" + assert f.get("nope", default=None) is None + + +def test_form_dict(): + f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) + assert list(f.keys()) == ["a", "b"] + assert list(f.values()) == ["123", "789"] + assert list(f.items()) == [("a", "123"), ("b", "789")] + assert list(f) == ["a", "b"] + assert dict(f) == {"a": "123", "b": "789"} + + +def test_form_multidict(): + f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) + assert f.get_list("a") == ["123", "456"] + assert f.multi_items() == [("a", "123"), ("a", "456"), ("b", "789")] + assert f.multi_dict() == {"a": ["123", "456"], "b": ["789"]} + + +def test_form_builtins(): + f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) + assert len(f) == 2 + assert bool(f) + assert hash(f) + assert f == httpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) + + +def test_form_copy_operations(): + f = httpx.Form([("a", "123"), ("a", "456"), ("b", "789")]) + assert f.copy_set("a", "abc") == httpx.Form([("a", "abc"), ("b", "789")]) + assert f.copy_append("a", "abc") == httpx.Form([("a", "123"), ("a", "456"), ("a", "abc"), ("b", "789")]) + assert f.copy_remove("a") == httpx.Form([("b", "789")]) + + +def test_form_encode(): + form = httpx.Form({'email': 'address@example.com'}) + assert form['email'] == "address@example.com" + + stream = form.encode() + content_type = form.content_type() + + assert stream.read() == b"email=address%40example.com" + assert content_type == "application/x-www-form-urlencoded" + + +# Files + +def test_files(): + f = httpx.Files() + assert f.multi_dict() == {} + assert repr(f) == "" + + +def test_files_from_dict(): + f = httpx.Files({ + "a": [ + httpx.File("123.json"), + httpx.File("456.json"), + ], + "b": httpx.File("789.json") + }) + assert f.multi_dict() == { + "a": [ + httpx.File("123.json"), + httpx.File("456.json"), + ], + "b": [ + httpx.File("789.json"), + ] + } + assert repr(f) == ( + "), ('a', ), ('b', )]>" + ) + + + +def test_files_from_list(): + f = httpx.Files([ + ("a", httpx.File("123.json")), + ("a", httpx.File("456.json")), + ("b", httpx.File("789.json")) + ]) + assert f.multi_dict() == { + "a": [ + httpx.File("123.json"), + httpx.File("456.json"), + ], + "b": [ + httpx.File("789.json"), + ] + } + assert repr(f) == ( + "), ('a', ), ('b', )]>" + ) + + +def test_files_accessors(): + f = httpx.Files([ + ("a", httpx.File("123.json")), + ("a", httpx.File("456.json")), + ("b", httpx.File("789.json")) + ]) + assert "a" in f + assert "A" not in f + assert "c" not in f + assert f["a"] == httpx.File("123.json") + assert f.get("a") == httpx.File("123.json") + assert f.get("nope", default=None) is None + + +def test_files_dict(): + f = httpx.Files([ + ("a", httpx.File("123.json")), + ("a", httpx.File("456.json")), + ("b", httpx.File("789.json")) + ]) + assert list(f.keys()) == ["a", "b"] + assert list(f.values()) == [httpx.File("123.json"), httpx.File("789.json")] + assert list(f.items()) == [("a", httpx.File("123.json")), ("b", httpx.File("789.json"))] + assert list(f) == ["a", "b"] + assert dict(f) == {"a": httpx.File("123.json"), "b": httpx.File("789.json")} + + +def test_files_multidict(): + f = httpx.Files([ + ("a", httpx.File("123.json")), + ("a", httpx.File("456.json")), + ("b", httpx.File("789.json")) + ]) + assert f.get_list("a") == [ + httpx.File("123.json"), + httpx.File("456.json"), + ] + assert f.multi_items() == [ + ("a", httpx.File("123.json")), + ("a", httpx.File("456.json")), + ("b", httpx.File("789.json")), + ] + assert f.multi_dict() == { + "a": [ + httpx.File("123.json"), + httpx.File("456.json"), + ], + "b": [ + httpx.File("789.json"), + ] + } + + +def test_files_builtins(): + f = httpx.Files([ + ("a", httpx.File("123.json")), + ("a", httpx.File("456.json")), + ("b", httpx.File("789.json")) + ]) + assert len(f) == 2 + assert bool(f) + assert f == httpx.Files([ + ("a", httpx.File("123.json")), + ("a", httpx.File("456.json")), + ("b", httpx.File("789.json")), + ]) + + +def test_multipart(): + with tempfile.NamedTemporaryFile() as f: + f.write(b"Hello, world") + f.seek(0) + + multipart = httpx.MultiPart( + form={'email': 'me@example.com'}, + files={'upload': httpx.File(f.name)}, + boundary='BOUNDARY', + ) + assert multipart.form['email'] == "me@example.com" + assert multipart.files['upload'] == httpx.File(f.name) + + fname = os.path.basename(f.name).encode('utf-8') + stream = multipart.encode() + content_type = multipart.content_type() + + content_type == "multipart/form-data; boundary=BOUNDARY" + content = stream.read() + assert content == ( + b'--BOUNDARY\r\n' + b'Content-Disposition: form-data; name="email"\r\n' + b'\r\n' + b'me@example.com\r\n' + b'--BOUNDARY\r\n' + b'Content-Disposition: form-data; name="upload"; filename="' + fname + b'"\r\n' + b'\r\n' + b'Hello, world\r\n' + b'--BOUNDARY--\r\n' + ) diff --git a/tests/test_httpx/test_headers.py b/tests/test_httpx/test_headers.py new file mode 100644 index 0000000..6ebb99d --- /dev/null +++ b/tests/test_httpx/test_headers.py @@ -0,0 +1,109 @@ +import httpx +import pytest + + +def test_headers_from_dict(): + headers = httpx.Headers({ + 'Content-Length': '1024', + 'Content-Type': 'text/plain; charset=utf-8', + }) + assert headers['Content-Length'] == '1024' + assert headers['Content-Type'] == 'text/plain; charset=utf-8' + + +def test_headers_from_list(): + headers = httpx.Headers([ + ('Location', 'https://www.example.com'), + ('Set-Cookie', 'session_id=3498jj489jhb98jn'), + ]) + assert headers['Location'] == 'https://www.example.com' + assert headers['Set-Cookie'] == 'session_id=3498jj489jhb98jn' + + +def test_header_keys(): + h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + assert list(h.keys()) == ["Accept", "User-Agent"] + + +def test_header_values(): + h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + assert list(h.values()) == ["*/*", "python/httpx"] + + +def test_header_items(): + h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + assert list(h.items()) == [("Accept", "*/*"), ("User-Agent", "python/httpx")] + + +def test_header_get(): + h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + assert h.get("User-Agent") == "python/httpx" + assert h.get("user-agent") == "python/httpx" + assert h.get("missing") is None + + +def test_header_copy_set(): + h = httpx.Headers({"Expires": "0"}) + h = h.copy_set("Expires", "Wed, 21 Oct 2015 07:28:00 GMT") + assert h == httpx.Headers({"Expires": "Wed, 21 Oct 2015 07:28:00 GMT"}) + + h = httpx.Headers({"Expires": "0"}) + h = h.copy_set("expires", "Wed, 21 Oct 2015 07:28:00 GMT") + assert h == httpx.Headers({"Expires": "Wed, 21 Oct 2015 07:28:00 GMT"}) + + +def test_header_copy_remove(): + h = httpx.Headers({"Accept": "*/*"}) + h = h.copy_remove("Accept") + assert h == httpx.Headers({}) + + h = httpx.Headers({"Accept": "*/*"}) + h = h.copy_remove("accept") + assert h == httpx.Headers({}) + + +def test_header_getitem(): + h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + assert h["User-Agent"] == "python/httpx" + assert h["user-agent"] == "python/httpx" + with pytest.raises(KeyError): + h["missing"] + + +def test_header_contains(): + h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + assert "User-Agent" in h + assert "user-agent" in h + assert "missing" not in h + + +def test_header_bool(): + h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + assert bool(h) + h = httpx.Headers() + assert not bool(h) + + +def test_header_iter(): + h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + assert [k for k in h] == ["Accept", "User-Agent"] + + +def test_header_len(): + h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + assert len(h) == 2 + + +def test_header_repr(): + h = httpx.Headers({"Accept": "*/*", "User-Agent": "python/httpx"}) + assert repr(h) == "" + + +def test_header_invalid_name(): + with pytest.raises(ValueError): + httpx.Headers({"Accept\n": "*/*"}) + + +def test_header_invalid_value(): + with pytest.raises(ValueError): + httpx.Headers({"Accept": "*/*\n"}) diff --git a/tests/test_httpx/test_network.py b/tests/test_httpx/test_network.py new file mode 100644 index 0000000..59b66f5 --- /dev/null +++ b/tests/test_httpx/test_network.py @@ -0,0 +1,101 @@ +import httpx +import pytest + + +def echo(stream): + while buffer := stream.read(): + stream.write(buffer) + + +@pytest.fixture +def server(): + net = httpx.NetworkBackend() + with net.serve("127.0.0.1", 8080, echo) as server: + yield server + + +def test_network_backend(): + net = httpx.NetworkBackend() + assert repr(net) in ["", ""] + + +def test_network_backend_connect(server): + net = httpx.NetworkBackend() + stream = net.connect(server.host, server.port) + try: + assert repr(stream) == f"" + stream.write(b"Hello, world.") + content = stream.read() + assert content == b"Hello, world." + finally: + stream.close() + + +def test_network_backend_context_managed(server): + net = httpx.NetworkBackend() + with net.connect(server.host, server.port) as stream: + stream.write(b"Hello, world.") + content = stream.read() + assert content == b"Hello, world." + assert repr(stream) == f"" + + +def test_network_backend_timeout(server): + net = httpx.NetworkBackend() + with httpx.timeout(0.0): + with pytest.raises(TimeoutError): + with net.connect(server.host, server.port) as stream: + pass + + with httpx.timeout(10.0): + with net.connect(server.host, server.port) as stream: + pass + + +# >>> net = httpx.NetworkBackend() +# >>> stream = net.connect("dev.encode.io", 80) +# >>> try: +# >>> ... +# >>> finally: +# >>> stream.close() +# >>> stream +# + +# import httpx +# import ssl +# import truststore + +# net = httpx.NetworkBackend() +# ctx = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT) +# req = b'\r\n'.join([ +# b'GET / HTTP/1.1', +# b'Host: www.example.com', +# b'User-Agent: python/dev', +# b'Connection: close', +# b'', +# ]) + +# # Use a 10 second overall timeout for the entire request/response. +# with timeout(10.0): +# # Use a 3 second timeout for the initial connection. +# with timeout(3.0) as t: +# # Open the connection & establish SSL. +# with net.open_stream("www.example.com", 443) as stream: +# stream.start_tls(ctx, hostname="www.example.com") +# t.cancel() +# # Send the request & read the response. +# stream.write(req) +# buffer = [] +# while part := stream.read(): +# buffer.append(part) +# resp = b''.join(buffer) + + +# def test_fixture(tcp_echo_server): +# host, port = (tcp_echo_server.host, tcp_echo_server.port) + +# net = httpx.NetworkBackend() +# with net.connect(host, port) as stream: +# stream.write(b"123") +# buffer = stream.read() +# assert buffer == b"123" diff --git a/tests/test_httpx/test_parsers.py b/tests/test_httpx/test_parsers.py new file mode 100644 index 0000000..62260d9 --- /dev/null +++ b/tests/test_httpx/test_parsers.py @@ -0,0 +1,747 @@ +import httpx +import pytest + + +class TrickleIO(httpx.Stream): + def __init__(self, stream: httpx.Stream): + self._stream = stream + + def read(self, size) -> bytes: + return self._stream.read(1) + + def write(self, data: bytes) -> None: + self._stream.write(data) + + def close(self) -> None: + self._stream.close() + + +def test_parser(): + stream = httpx.DuplexStream( + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: 12\r\n" + b"Content-Type: text/plain\r\n" + b"\r\n" + b"hello, world" + ) + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"POST", b"/", b"HTTP/1.1") + p.send_headers([ + (b"Host", b"example.com"), + (b"Content-Type", b"application/json"), + (b"Content-Length", b"23"), + ]) + p.send_body(b'{"msg": "hello, world"}') + p.send_body(b'') + + assert stream.input_bytes() == ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: 12\r\n" + b"Content-Type: text/plain\r\n" + b"\r\n" + b"hello, world" + ) + assert stream.output_bytes() == ( + b"POST / HTTP/1.1\r\n" + b"Host: example.com\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: 23\r\n" + b"\r\n" + b'{"msg": "hello, world"}' + ) + + protocol, code, reason_phase = p.recv_status_line() + headers = p.recv_headers() + body = p.recv_body() + terminator = p.recv_body() + + assert protocol == b'HTTP/1.1' + assert code == 200 + assert reason_phase == b'OK' + assert headers == [ + (b'Content-Length', b'12'), + (b'Content-Type', b'text/plain'), + ] + assert body == b'hello, world' + assert terminator == b'' + + assert not p.is_idle() + p.complete() + assert p.is_idle() + + +def test_parser_server(): + stream = httpx.DuplexStream( + b"GET / HTTP/1.1\r\n" + b"Host: www.example.com\r\n" + b"\r\n" + ) + + p = httpx.HTTPParser(stream, mode='SERVER') + method, target, protocol = p.recv_method_line() + headers = p.recv_headers() + body = p.recv_body() + + assert method == b'GET' + assert target == b'/' + assert protocol == b'HTTP/1.1' + assert headers == [ + (b'Host', b'www.example.com'), + ] + assert body == b'' + + p.send_status_line(b"HTTP/1.1", 200, b"OK") + p.send_headers([ + (b"Content-Type", b"application/json"), + (b"Content-Length", b"23"), + ]) + p.send_body(b'{"msg": "hello, world"}') + p.send_body(b'') + + assert stream.input_bytes() == ( + b"GET / HTTP/1.1\r\n" + b"Host: www.example.com\r\n" + b"\r\n" + ) + assert stream.output_bytes() == ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: 23\r\n" + b"\r\n" + b'{"msg": "hello, world"}' + ) + + assert not p.is_idle() + p.complete() + assert p.is_idle() + + +def test_parser_trickle(): + stream = httpx.DuplexStream( + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: 12\r\n" + b"Content-Type: text/plain\r\n" + b"\r\n" + b"hello, world" + ) + + p = httpx.HTTPParser(TrickleIO(stream), mode='CLIENT') + p.send_method_line(b"POST", b"/", b"HTTP/1.1") + p.send_headers([ + (b"Host", b"example.com"), + (b"Content-Type", b"application/json"), + (b"Content-Length", b"23"), + ]) + p.send_body(b'{"msg": "hello, world"}') + p.send_body(b'') + + assert stream.input_bytes() == ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: 12\r\n" + b"Content-Type: text/plain\r\n" + b"\r\n" + b"hello, world" + ) + assert stream.output_bytes() == ( + b"POST / HTTP/1.1\r\n" + b"Host: example.com\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: 23\r\n" + b"\r\n" + b'{"msg": "hello, world"}' + ) + + protocol, code, reason_phase = p.recv_status_line() + headers = p.recv_headers() + body = p.recv_body() + terminator = p.recv_body() + + assert protocol == b'HTTP/1.1' + assert code == 200 + assert reason_phase == b'OK' + assert headers == [ + (b'Content-Length', b'12'), + (b'Content-Type', b'text/plain'), + ] + assert body == b'hello, world' + assert terminator == b'' + + +def test_parser_transfer_encoding_chunked(): + stream = httpx.DuplexStream( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Transfer-Encoding: chunked\r\n" + b"\r\n" + b"c\r\n" + b"hello, world\r\n" + b"0\r\n\r\n" + ) + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"POST", b"/", b"HTTP/1.1") + p.send_headers([ + (b"Host", b"example.com"), + (b"Content-Type", b"application/json"), + (b"Transfer-Encoding", b"chunked"), + ]) + p.send_body(b'{"msg": "hello, world"}') + p.send_body(b'') + + assert stream.input_bytes() == ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Transfer-Encoding: chunked\r\n" + b"\r\n" + b"c\r\n" + b"hello, world\r\n" + b"0\r\n\r\n" + ) + assert stream.output_bytes() == ( + b"POST / HTTP/1.1\r\n" + b"Host: example.com\r\n" + b"Content-Type: application/json\r\n" + b"Transfer-Encoding: chunked\r\n" + b"\r\n" + b'17\r\n' + b'{"msg": "hello, world"}\r\n' + b'0\r\n\r\n' + ) + + protocol, code, reason_phase = p.recv_status_line() + headers = p.recv_headers() + body = p.recv_body() + terminator = p.recv_body() + + assert protocol == b'HTTP/1.1' + assert code == 200 + assert reason_phase == b'OK' + assert headers == [ + (b'Content-Type', b'text/plain'), + (b'Transfer-Encoding', b'chunked'), + ] + assert body == b'hello, world' + assert terminator == b'' + + +def test_parser_transfer_encoding_chunked_trickle(): + stream = httpx.DuplexStream( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Transfer-Encoding: chunked\r\n" + b"\r\n" + b"c\r\n" + b"hello, world\r\n" + b"0\r\n\r\n" + ) + + p = httpx.HTTPParser(TrickleIO(stream), mode='CLIENT') + p.send_method_line(b"POST", b"/", b"HTTP/1.1") + p.send_headers([ + (b"Host", b"example.com"), + (b"Content-Type", b"application/json"), + (b"Transfer-Encoding", b"chunked"), + ]) + p.send_body(b'{"msg": "hello, world"}') + p.send_body(b'') + + assert stream.input_bytes() == ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Transfer-Encoding: chunked\r\n" + b"\r\n" + b"c\r\n" + b"hello, world\r\n" + b"0\r\n\r\n" + ) + assert stream.output_bytes() == ( + b"POST / HTTP/1.1\r\n" + b"Host: example.com\r\n" + b"Content-Type: application/json\r\n" + b"Transfer-Encoding: chunked\r\n" + b"\r\n" + b'17\r\n' + b'{"msg": "hello, world"}\r\n' + b'0\r\n\r\n' + ) + + protocol, code, reason_phase = p.recv_status_line() + headers = p.recv_headers() + body = p.recv_body() + terminator = p.recv_body() + + assert protocol == b'HTTP/1.1' + assert code == 200 + assert reason_phase == b'OK' + assert headers == [ + (b'Content-Type', b'text/plain'), + (b'Transfer-Encoding', b'chunked'), + ] + assert body == b'hello, world' + assert terminator == b'' + + +def test_parser_repr(): + stream = httpx.DuplexStream( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: application/json\r\n" + b"Content-Length: 23\r\n" + b"\r\n" + b'{"msg": "hello, world"}' + ) + + p = httpx.HTTPParser(stream, mode='CLIENT') + assert repr(p) == "" + + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + assert repr(p) == "" + + p.send_headers([(b"Host", b"example.com")]) + assert repr(p) == "" + + p.send_body(b'') + assert repr(p) == "" + + p.recv_status_line() + assert repr(p) == "" + + p.recv_headers() + assert repr(p) == "" + + p.recv_body() + assert repr(p) == "" + + p.recv_body() + assert repr(p) == "" + + p.complete() + assert repr(p) == "" + + +def test_parser_invalid_transitions(): + stream = httpx.DuplexStream() + + with pytest.raises(httpx.ProtocolError): + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b'GET', b'/', b'HTTP/1.1') + p.send_method_line(b'GET', b'/', b'HTTP/1.1') + + with pytest.raises(httpx.ProtocolError): + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_headers([]) + + with pytest.raises(httpx.ProtocolError): + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_body(b'') + + with pytest.raises(httpx.ProtocolError): + reader = httpx.ByteStream(b'HTTP/1.1 200 OK\r\n') + p = httpx.HTTPParser(stream, mode='CLIENT') + p.recv_status_line() + + with pytest.raises(httpx.ProtocolError): + p = httpx.HTTPParser(stream, mode='CLIENT') + p.recv_headers() + + with pytest.raises(httpx.ProtocolError): + p = httpx.HTTPParser(stream, mode='CLIENT') + p.recv_body() + + +def test_parser_invalid_status_line(): + # ... + stream = httpx.DuplexStream(b'...') + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p.send_headers([(b"Host", b"example.com")]) + p.send_body(b'') + + msg = 'Stream closed early reading response status line' + with pytest.raises(httpx.ProtocolError, match=msg): + p.recv_status_line() + + # ... + stream = httpx.DuplexStream(b'HTTP/1.1' + b'x' * 5000) + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p.send_headers([(b"Host", b"example.com")]) + p.send_body(b'') + + msg = 'Exceeded maximum size reading response status line' + with pytest.raises(httpx.ProtocolError, match=msg): + p.recv_status_line() + + # ... + stream = httpx.DuplexStream(b'HTTP/1.1' + b'x' * 5000 + b'\r\n') + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p.send_headers([(b"Host", b"example.com")]) + p.send_body(b'') + + msg = 'Exceeded maximum size reading response status line' + with pytest.raises(httpx.ProtocolError, match=msg): + p.recv_status_line() + + +def test_parser_sent_unsupported_protocol(): + # Currently only HTTP/1.1 is supported. + stream = httpx.DuplexStream() + + p = httpx.HTTPParser(stream, mode='CLIENT') + msg = 'Sent unsupported protocol version' + with pytest.raises(httpx.ProtocolError, match=msg): + p.send_method_line(b"GET", b"/", b"HTTP/1.0") + + +def test_parser_recv_unsupported_protocol(): + # Currently only HTTP/1.1 is supported. + stream = httpx.DuplexStream(b"HTTP/1.0 200 OK\r\n") + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + msg = 'Received unsupported protocol version' + with pytest.raises(httpx.ProtocolError, match=msg): + p.recv_status_line() + + +def test_parser_large_body(): + body = b"x" * 6988 + + stream = httpx.DuplexStream( + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: 6988\r\n" + b"Content-Type: text/plain\r\n" + b"\r\n" + body + ) + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p.send_headers([(b"Host", b"example.com")]) + p.send_body(b'') + + # Checkout our buffer sizes. + p.recv_status_line() + p.recv_headers() + assert len(p.recv_body()) == 4096 + assert len(p.recv_body()) == 2892 + assert len(p.recv_body()) == 0 + +def test_parser_stream_large_body(): + body = b"x" * 6956 + + stream = httpx.DuplexStream( + b"HTTP/1.1 200 OK\r\n" + b"Transfer-Encoding: chunked\r\n" + b"Content-Type: text/plain\r\n" + b"\r\n" + b"1b2c\r\n" + body + b'\r\n0\r\n\r\n' + ) + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p.send_headers([(b"Host", b"example.com")]) + p.send_body(b'') + + # Checkout our buffer sizes. + p.recv_status_line() + p.recv_headers() + # assert len(p.recv_body()) == 4096 + # assert len(p.recv_body()) == 2860 + assert len(p.recv_body()) == 6956 + assert len(p.recv_body()) == 0 + + +def test_parser_not_enough_data_received(): + stream = httpx.DuplexStream( + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: 188\r\n" + b"Content-Type: text/plain\r\n" + b"\r\n" + b"truncated" + ) + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p.send_headers([(b"Host", b"example.com")]) + p.send_body(b'') + + # Checkout our buffer sizes. + p.recv_status_line() + p.recv_headers() + p.recv_body() + msg = 'Not enough data received for declared Content-Length' + with pytest.raises(httpx.ProtocolError, match=msg): + p.recv_body() + + +def test_parser_not_enough_data_sent(): + stream = httpx.DuplexStream() + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"POST", b"/", b"HTTP/1.1") + p.send_headers([ + (b"Host", b"example.com"), + (b"Content-Type", b"application/json"), + (b"Content-Length", b"23"), + ]) + p.send_body(b'{"msg": "too smol"}') + msg = 'Not enough data sent for declared Content-Length' + with pytest.raises(httpx.ProtocolError, match=msg): + p.send_body(b'') + + +def test_parser_too_much_data_sent(): + stream = httpx.DuplexStream() + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"POST", b"/", b"HTTP/1.1") + p.send_headers([ + (b"Host", b"example.com"), + (b"Content-Type", b"application/json"), + (b"Content-Length", b"19"), + ]) + msg = 'Too much data sent for declared Content-Length' + with pytest.raises(httpx.ProtocolError, match=msg): + p.send_body(b'{"msg": "too chonky"}') + + +def test_parser_missing_host_header(): + stream = httpx.DuplexStream() + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + msg = "Request missing 'Host' header" + with pytest.raises(httpx.ProtocolError, match=msg): + p.send_headers([]) + + +def test_client_connection_close(): + stream = httpx.DuplexStream( + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: 12\r\n" + b"Content-Type: text/plain\r\n" + b"\r\n" + b"hello, world" + ) + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p.send_headers([ + (b"Host", b"example.com"), + (b"Connection", b"close"), + ]) + p.send_body(b'') + + protocol, code, reason_phase = p.recv_status_line() + headers = p.recv_headers() + body = p.recv_body() + terminator = p.recv_body() + + assert protocol == b'HTTP/1.1' + assert code == 200 + assert reason_phase == b"OK" + assert headers == [ + (b'Content-Length', b'12'), + (b'Content-Type', b'text/plain'), + ] + assert body == b"hello, world" + assert terminator == b"" + + assert repr(p) == "" + + p.complete() + assert repr(p) == "" + assert p.is_closed() + + +def test_server_connection_close(): + stream = httpx.DuplexStream( + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: 12\r\n" + b"Content-Type: text/plain\r\n" + b"Connection: close\r\n" + b"\r\n" + b"hello, world" + ) + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p.send_headers([(b"Host", b"example.com")]) + p.send_body(b'') + + protocol, code, reason_phase = p.recv_status_line() + headers = p.recv_headers() + body = p.recv_body() + terminator = p.recv_body() + + assert protocol == b'HTTP/1.1' + assert code == 200 + assert reason_phase == b"OK" + assert headers == [ + (b'Content-Length', b'12'), + (b'Content-Type', b'text/plain'), + (b'Connection', b'close'), + ] + assert body == b"hello, world" + assert terminator == b"" + + assert repr(p) == "" + p.complete() + assert repr(p) == "" + + +def test_invalid_status_code(): + stream = httpx.DuplexStream( + b"HTTP/1.1 99 OK\r\n" + b"Content-Length: 12\r\n" + b"Content-Type: text/plain\r\n" + b"\r\n" + b"hello, world" + ) + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p.send_headers([ + (b"Host", b"example.com"), + (b"Connection", b"close"), + ]) + p.send_body(b'') + + msg = "Received invalid status code" + with pytest.raises(httpx.ProtocolError, match=msg): + p.recv_status_line() + + +def test_1xx_status_code(): + stream = httpx.DuplexStream( + b"HTTP/1.1 103 Early Hints\r\n" + b"Link: ; rel=preload; as=style\r\n" + b"Link: ; rel=preload; as=script\r\n" + b"\r\n" + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: 12\r\n" + b"Content-Type: text/plain\r\n" + b"\r\n" + b"hello, world" + ) + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p.send_headers([(b"Host", b"example.com")]) + p.send_body(b'') + + protocol, code, reason_phase = p.recv_status_line() + headers = p.recv_headers() + + assert protocol == b'HTTP/1.1' + assert code == 103 + assert reason_phase == b'Early Hints' + assert headers == [ + (b'Link', b'; rel=preload; as=style'), + (b'Link', b'; rel=preload; as=script'), + ] + + protocol, code, reason_phase = p.recv_status_line() + headers = p.recv_headers() + body = p.recv_body() + terminator = p.recv_body() + + assert protocol == b'HTTP/1.1' + assert code == 200 + assert reason_phase == b"OK" + assert headers == [ + (b'Content-Length', b'12'), + (b'Content-Type', b'text/plain'), + ] + assert body == b"hello, world" + assert terminator == b"" + + +def test_received_invalid_content_length(): + stream = httpx.DuplexStream( + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: -999\r\n" + b"Content-Type: text/plain\r\n" + b"\r\n" + b"hello, world" + ) + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p.send_headers([ + (b"Host", b"example.com"), + (b"Connection", b"close"), + ]) + p.send_body(b'') + + p.recv_status_line() + msg = "Received invalid Content-Length" + with pytest.raises(httpx.ProtocolError, match=msg): + p.recv_headers() + + +def test_sent_invalid_content_length(): + stream = httpx.DuplexStream() + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + msg = "Sent invalid Content-Length" + with pytest.raises(httpx.ProtocolError, match=msg): + # Limited to 20 digits. + # 100 million terabytes should be enough for anyone. + p.send_headers([ + (b"Host", b"example.com"), + (b"Content-Length", b"100000000000000000000"), + ]) + + +def test_received_invalid_characters_in_chunk_size(): + stream = httpx.DuplexStream( + b"HTTP/1.1 200 OK\r\n" + b"Transfer-Encoding: chunked\r\n" + b"Content-Type: text/plain\r\n" + b"\r\n" + b"0xFF\r\n..." + ) + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p.send_headers([ + (b"Host", b"example.com"), + (b"Connection", b"close"), + ]) + p.send_body(b'') + + p.recv_status_line() + p.recv_headers() + msg = "Received invalid chunk size" + with pytest.raises(httpx.ProtocolError, match=msg): + p.recv_body() + + +def test_received_oversized_chunk(): + stream = httpx.DuplexStream( + b"HTTP/1.1 200 OK\r\n" + b"Transfer-Encoding: chunked\r\n" + b"Content-Type: text/plain\r\n" + b"\r\n" + b"FFFFFFFFFF\r\n..." + ) + + p = httpx.HTTPParser(stream, mode='CLIENT') + p.send_method_line(b"GET", b"/", b"HTTP/1.1") + p.send_headers([ + (b"Host", b"example.com"), + (b"Connection", b"close"), + ]) + p.send_body(b'') + + p.recv_status_line() + p.recv_headers() + msg = "Received invalid chunk size" + with pytest.raises(httpx.ProtocolError, match=msg): + p.recv_body() diff --git a/tests/test_httpx/test_pool.py b/tests/test_httpx/test_pool.py new file mode 100644 index 0000000..04cd024 --- /dev/null +++ b/tests/test_httpx/test_pool.py @@ -0,0 +1,126 @@ +import httpx +import pytest + + +def hello_world(request): + content = httpx.Text('Hello, world.') + return httpx.Response(200, content=content) + + +@pytest.fixture +def server(): + with httpx.serve_http(hello_world) as server: + yield server + + +def test_connection_pool_request(server): + with httpx.ConnectionPool() as pool: + assert repr(pool) == "" + assert len(pool.connections) == 0 + + r = pool.request("GET", server.url) + + assert r.status_code == 200 + assert repr(pool) == "" + assert len(pool.connections) == 1 + + +def test_connection_pool_connection_close(server): + with httpx.ConnectionPool() as pool: + assert repr(pool) == "" + assert len(pool.connections) == 0 + + r = pool.request("GET", server.url, headers={"Connection": "close"}) + + # TODO: Really we want closed connections proactively removed from the pool, + assert r.status_code == 200 + assert repr(pool) == "" + assert len(pool.connections) == 1 + + +def test_connection_pool_stream(server): + with httpx.ConnectionPool() as pool: + assert repr(pool) == "" + assert len(pool.connections) == 0 + + with pool.stream("GET", server.url) as r: + assert r.status_code == 200 + assert repr(pool) == "" + assert len(pool.connections) == 1 + r.read() + + assert repr(pool) == "" + assert len(pool.connections) == 1 + + +def test_connection_pool_cannot_request_after_closed(server): + with httpx.ConnectionPool() as pool: + pool + + with pytest.raises(RuntimeError): + pool.request("GET", server.url) + + +def test_connection_pool_should_have_managed_lifespan(server): + pool = httpx.ConnectionPool() + with pytest.warns(UserWarning): + del pool + + +def test_connection_request(server): + with httpx.open_connection(server.url) as conn: + assert repr(conn) == f"" + + r = conn.request("GET", "/") + + assert r.status_code == 200 + assert repr(conn) == f"" + + +def test_connection_stream(server): + with httpx.open_connection(server.url) as conn: + assert repr(conn) == f"" + with conn.stream("GET", "/") as r: + assert r.status_code == 200 + assert repr(conn) == f"" + r.read() + assert repr(conn) == f"" + + +# # with httpx.open_connection("https://www.example.com/") as conn: +# # r = conn.request("GET", "/") + +# # >>> pool = httpx.ConnectionPool() +# # >>> pool +# # + +# # >>> with httpx.open_connection_pool() as pool: +# # >>> res = pool.request("GET", "https://www.example.com") +# # >>> res, pool +# # , + +# # >>> with httpx.open_connection_pool() as pool: +# # >>> with pool.stream("GET", "https://www.example.com") as res: +# # >>> res, pool +# # , + +# # >>> with httpx.open_connection_pool() as pool: +# # >>> req = httpx.Request("GET", "https://www.example.com") +# # >>> with pool.send(req) as res: +# # >>> res.body() +# # >>> res, pool +# # , + +# # >>> with httpx.open_connection_pool() as pool: +# # >>> pool.close() +# # + +# # with httpx.open_connection("https://www.example.com/") as conn: +# # with conn.upgrade("GET", "/feed", {"Upgrade": "WebSocket") as stream: +# # ... + +# # with httpx.open_connection("http://127.0.0.1:8080") as conn: +# # with conn.upgrade("CONNECT", "www.encode.io:443") as stream: +# # stream.start_tls(ctx, hostname="www.encode.io") +# # ... + diff --git a/tests/test_httpx/test_quickstart.py b/tests/test_httpx/test_quickstart.py new file mode 100644 index 0000000..55c34b1 --- /dev/null +++ b/tests/test_httpx/test_quickstart.py @@ -0,0 +1,78 @@ +import json +import httpx +import pytest + + +def echo(request): + request.read() + response = httpx.Response(200, content=httpx.JSON({ + 'method': request.method, + 'query-params': dict(request.url.params.items()), + 'content-type': request.headers.get('Content-Type'), + 'json': json.loads(request.body) if request.body else None, + })) + return response + + +@pytest.fixture +def server(): + with httpx.serve_http(echo) as server: + yield server + + +def test_get(server): + r = httpx.get(server.url) + assert r.status_code == 200 + assert json.loads(r.body) == { + 'method': 'GET', + 'query-params': {}, + 'content-type': None, + 'json': None, + } + + +def test_post(server): + data = httpx.JSON({"data": 123}) + r = httpx.post(server.url, content=data) + assert r.status_code == 200 + assert json.loads(r.body) == { + 'method': 'POST', + 'query-params': {}, + 'content-type': 'application/json', + 'json': {"data": 123}, + } + + +def test_put(server): + data = httpx.JSON({"data": 123}) + r = httpx.put(server.url, content=data) + assert r.status_code == 200 + assert json.loads(r.body) == { + 'method': 'PUT', + 'query-params': {}, + 'content-type': 'application/json', + 'json': {"data": 123}, + } + + +def test_patch(server): + data = httpx.JSON({"data": 123}) + r = httpx.patch(server.url, content=data) + assert r.status_code == 200 + assert json.loads(r.body) == { + 'method': 'PATCH', + 'query-params': {}, + 'content-type': 'application/json', + 'json': {"data": 123}, + } + + +def test_delete(server): + r = httpx.delete(server.url) + assert r.status_code == 200 + assert json.loads(r.body) == { + 'method': 'DELETE', + 'query-params': {}, + 'content-type': None, + 'json': None, + } diff --git a/tests/test_httpx/test_request.py b/tests/test_httpx/test_request.py new file mode 100644 index 0000000..47e5c4d --- /dev/null +++ b/tests/test_httpx/test_request.py @@ -0,0 +1,80 @@ +import httpx +import pytest + + +class ByteIterator: + def __init__(self, buffer=b""): + self._buffer = buffer + + def next(self) -> bytes: + buffer = self._buffer + self._buffer = b'' + return buffer + + +def test_request(): + r = httpx.Request("GET", "https://example.com") + + assert repr(r) == "" + assert r.method == "GET" + assert r.url == "https://example.com" + assert r.headers == { + "Host": "example.com" + } + assert r.read() == b"" + +def test_request_bytes(): + content = b"Hello, world" + r = httpx.Request("POST", "https://example.com", content=content) + + assert repr(r) == "" + assert r.method == "POST" + assert r.url == "https://example.com" + assert r.headers == { + "Host": "example.com", + "Content-Length": "12", + } + assert r.read() == b"Hello, world" + + +def test_request_stream(): + i = ByteIterator(b"Hello, world") + stream = httpx.HTTPStream(i.next, None) + r = httpx.Request("POST", "https://example.com", content=stream) + + assert repr(r) == "" + assert r.method == "POST" + assert r.url == "https://example.com" + assert r.headers == { + "Host": "example.com", + "Transfer-Encoding": "chunked", + } + assert r.read() == b"Hello, world" + + +def test_request_json(): + data = httpx.JSON({"msg": "Hello, world"}) + r = httpx.Request("POST", "https://example.com", content=data) + + assert repr(r) == "" + assert r.method == "POST" + assert r.url == "https://example.com" + assert r.headers == { + "Host": "example.com", + "Content-Length": "22", + "Content-Type": "application/json", + } + assert r.read() == b'{"msg":"Hello, world"}' + + +def test_request_empty_post(): + r = httpx.Request("POST", "https://example.com") + + assert repr(r) == "" + assert r.method == "POST" + assert r.url == "https://example.com" + assert r.headers == { + "Host": "example.com", + "Content-Length": "0", + } + assert r.read() == b'' diff --git a/tests/test_httpx/test_response.py b/tests/test_httpx/test_response.py new file mode 100644 index 0000000..94efdce --- /dev/null +++ b/tests/test_httpx/test_response.py @@ -0,0 +1,65 @@ +import httpx +import pytest + + +class ByteIterator: + def __init__(self, buffer=b""): + self._buffer = buffer + + def next(self) -> bytes: + buffer = self._buffer + self._buffer = b'' + return buffer + + +def test_response(): + r = httpx.Response(200) + + assert repr(r) == "" + assert r.status_code == 200 + assert r.headers == {'Content-Length': '0'} + assert r.read() == b"" + + +def test_response_204(): + r = httpx.Response(204) + + assert repr(r) == "" + assert r.status_code == 204 + assert r.headers == {} + assert r.read() == b"" + + +def test_response_bytes(): + content = b"Hello, world" + r = httpx.Response(200, content=content) + + assert repr(r) == "" + assert r.headers == { + "Content-Length": "12", + } + assert r.read() == b"Hello, world" + + +def test_response_stream(): + i = ByteIterator(b"Hello, world") + stream = httpx.HTTPStream(i.next, None) + r = httpx.Response(200, content=stream) + + assert repr(r) == "" + assert r.headers == { + "Transfer-Encoding": "chunked", + } + assert r.read() == b"Hello, world" + + +def test_response_json(): + data = httpx.JSON({"msg": "Hello, world"}) + r = httpx.Response(200, content=data) + + assert repr(r) == "" + assert r.headers == { + "Content-Length": "22", + "Content-Type": "application/json", + } + assert r.read() == b'{"msg":"Hello, world"}' diff --git a/tests/test_httpx/test_streams.py b/tests/test_httpx/test_streams.py new file mode 100644 index 0000000..41ae812 --- /dev/null +++ b/tests/test_httpx/test_streams.py @@ -0,0 +1,81 @@ +import pytest +import httpx + + +def test_stream(): + i = httpx.Stream() + with pytest.raises(NotImplementedError): + i.read() + + with pytest.raises(NotImplementedError): + i.close() + + i.size == None + + +def test_bytestream(): + data = b'abc' + s = httpx.ByteStream(data) + assert s.size == 3 + assert s.read() == b'abc' + + s = httpx.ByteStream(data) + assert s.read(1) == b'a' + assert s.read(1) == b'b' + assert s.read(1) == b'c' + assert s.read(1) == b'' + + +def test_filestream(tmp_path): + path = tmp_path / "example.txt" + path.write_bytes(b"hello world") + + with httpx.FileStream(path) as s: + assert s.size == 11 + assert s.read() == b'hello world' + + with httpx.FileStream(path) as s: + assert s.read(5) == b'hello' + assert s.read(5) == b' worl' + assert s.read(5) == b'd' + assert s.read(5) == b'' + + with httpx.FileStream(path) as s: + assert s.read(5) == b'hello' + + +def test_multipartstream(tmp_path): + path = tmp_path / 'example.txt' + path.write_bytes(b'hello world' + b'x' * 50) + + expected = b''.join([ + b'--boundary\r\n', + b'Content-Disposition: form-data; name="email"\r\n', + b'\r\n', + b'heya@example.com\r\n', + b'--boundary\r\n', + b'Content-Disposition: form-data; name="upload"; filename="example.txt"\r\n', + b'\r\n', + b'hello world' + ( b'x' * 50) + b'\r\n', + b'--boundary--\r\n', + ]) + + form = [('email', 'heya@example.com')] + files = [('upload', str(path))] + with httpx.MultiPartStream(form, files, boundary='boundary') as s: + assert s.size is None + assert s.read() == expected + + with httpx.MultiPartStream(form, files, boundary='boundary') as s: + assert s.read(50) == expected[:50] + assert s.read(50) == expected[50:100] + assert s.read(50) == expected[100:150] + assert s.read(50) == expected[150:200] + assert s.read(50) == expected[200:250] + + with httpx.MultiPartStream(form, files, boundary='boundary') as s: + assert s.read(50) == expected[:50] + assert s.read(50) == expected[50:100] + assert s.read(50) == expected[100:150] + assert s.read(50) == expected[150:200] + s.close() # test close during open file diff --git a/tests/test_httpx/test_urlencode.py b/tests/test_httpx/test_urlencode.py new file mode 100644 index 0000000..42ba45a --- /dev/null +++ b/tests/test_httpx/test_urlencode.py @@ -0,0 +1,33 @@ +import httpx + + +def test_urlencode(): + qs = "a=name%40example.com&a=456&b=7+8+9&c" + d = httpx.urldecode(qs) + assert d == { + "a": ["name@example.com", "456"], + "b": ["7 8 9"], + "c": [""] + } + + +def test_urldecode(): + d = { + "a": ["name@example.com", "456"], + "b": ["7 8 9"], + "c": [""] + } + qs = httpx.urlencode(d) + assert qs == "a=name%40example.com&a=456&b=7+8+9&c=" + + +def test_urlencode_empty(): + qs = "" + d = httpx.urldecode(qs) + assert d == {} + + +def test_urldecode_empty(): + d = {} + qs = httpx.urlencode(d) + assert qs == "" diff --git a/tests/test_httpx/test_urls.py b/tests/test_httpx/test_urls.py new file mode 100644 index 0000000..ad72935 --- /dev/null +++ b/tests/test_httpx/test_urls.py @@ -0,0 +1,164 @@ +import httpx +import pytest + + +def test_url(): + url = httpx.URL('https://www.example.com/') + assert str(url) == "https://www.example.com/" + + +def test_url_repr(): + url = httpx.URL('https://www.example.com/') + assert repr(url) == "" + + +def test_url_params(): + url = httpx.URL('https://www.example.com/', params={"a": "b", "c": "d"}) + assert str(url) == "https://www.example.com/?a=b&c=d" + + +def test_url_normalisation(): + url = httpx.URL('https://www.EXAMPLE.com:443/path/../main') + assert str(url) == 'https://www.example.com/main' + + +def test_url_relative(): + url = httpx.URL('/README.md') + assert str(url) == '/README.md' + + +def test_url_escaping(): + url = httpx.URL('https://example.com/path to here?search=🦋') + assert str(url) == 'https://example.com/path%20to%20here?search=%F0%9F%A6%8B' + + +def test_url_components(): + url = httpx.URL(scheme="https", host="example.com", path="/") + assert str(url) == 'https://example.com/' + + +# QueryParams + +def test_queryparams(): + params = httpx.QueryParams({"color": "black", "size": "medium"}) + assert str(params) == 'color=black&size=medium' + + +def test_queryparams_repr(): + params = httpx.QueryParams({"color": "black", "size": "medium"}) + assert repr(params) == "" + + +def test_queryparams_list_of_values(): + params = httpx.QueryParams({"filter": ["60GHz", "75GHz", "100GHz"]}) + assert str(params) == 'filter=60GHz&filter=75GHz&filter=100GHz' + + +def test_queryparams_from_str(): + params = httpx.QueryParams("color=black&size=medium") + assert str(params) == 'color=black&size=medium' + + +def test_queryparams_access(): + params = httpx.QueryParams("sort_by=published&author=natalie") + assert params["sort_by"] == 'published' + + +def test_queryparams_escaping(): + params = httpx.QueryParams({"email": "user@example.com", "search": "How HTTP works!"}) + assert str(params) == 'email=user%40example.com&search=How+HTTP+works%21' + + +def test_queryparams_empty(): + q = httpx.QueryParams({"a": ""}) + assert str(q) == "a=" + + q = httpx.QueryParams("a=") + assert str(q) == "a=" + + q = httpx.QueryParams("a") + assert str(q) == "a=" + + +def test_queryparams_set(): + q = httpx.QueryParams("a=123") + q = q.copy_set("a", "456") + assert q == httpx.QueryParams("a=456") + + +def test_queryparams_append(): + q = httpx.QueryParams("a=123") + q = q.copy_append("a", "456") + assert q == httpx.QueryParams("a=123&a=456") + + +def test_queryparams_remove(): + q = httpx.QueryParams("a=123") + q = q.copy_remove("a") + assert q == httpx.QueryParams("") + + +def test_queryparams_merge(): + q = httpx.QueryParams("a=123") + q = q.copy_update({"b": "456"}) + assert q == httpx.QueryParams("a=123&b=456") + q = q.copy_update({"a": "000", "c": "789"}) + assert q == httpx.QueryParams("a=000&b=456&c=789") + + +def test_queryparams_are_hashable(): + params = ( + httpx.QueryParams("a=123"), + httpx.QueryParams({"a": "123"}), + httpx.QueryParams("b=456"), + httpx.QueryParams({"b": "456"}), + ) + + assert len(set(params)) == 2 + + +@pytest.mark.parametrize( + "source", + [ + "a=123&a=456&b=789", + {"a": ["123", "456"], "b": "789"}, + {"a": ("123", "456"), "b": "789"}, + [("a", "123"), ("a", "456"), ("b", "789")], + (("a", "123"), ("a", "456"), ("b", "789")), + ], +) +def test_queryparams_misc(source): + q = httpx.QueryParams(source) + assert "a" in q + assert "A" not in q + assert "c" not in q + assert q["a"] == "123" + assert q.get("a") == "123" + assert q.get("nope", default=None) is None + assert q.get_list("a") == ["123", "456"] + assert bool(q) + + assert list(q.keys()) == ["a", "b"] + assert list(q.values()) == ["123", "789"] + assert list(q.items()) == [("a", "123"), ("b", "789")] + assert len(q) == 2 + assert list(q) == ["a", "b"] + assert dict(q) == {"a": "123", "b": "789"} + assert str(q) == "a=123&a=456&b=789" + assert httpx.QueryParams({"a": "123", "b": "456"}) == httpx.QueryParams( + [("a", "123"), ("b", "456")] + ) + assert httpx.QueryParams({"a": "123", "b": "456"}) == httpx.QueryParams( + "a=123&b=456" + ) + assert httpx.QueryParams({"a": "123", "b": "456"}) == httpx.QueryParams( + {"b": "456", "a": "123"} + ) + assert httpx.QueryParams() == httpx.QueryParams({}) + assert httpx.QueryParams([("a", "123"), ("a", "456")]) == httpx.QueryParams( + "a=123&a=456" + ) + assert httpx.QueryParams({"a": "123", "b": "456"}) != "invalid" + + q = httpx.QueryParams([("a", "123"), ("a", "456")]) + assert httpx.QueryParams(q) == q From 11ae1dd2222dd482ff3cdadc1b7440efb03d2e83 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 26 Feb 2026 17:31:06 +0000 Subject: [PATCH 09/15] Add __init__.py --- tests/test_httpx/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/test_httpx/__init__.py diff --git a/tests/test_httpx/__init__.py b/tests/test_httpx/__init__.py new file mode 100644 index 0000000..e69de29 From 819e85ce13a2cefbd83c8e05a1b09548d2b53cdc Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 26 Feb 2026 17:34:25 +0000 Subject: [PATCH 10/15] Cleaner test runs --- scripts/test | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/test b/scripts/test index 1e0812c..6152633 100755 --- a/scripts/test +++ b/scripts/test @@ -5,6 +5,7 @@ if [ -d 'venv' ] ; then export PREFIX="venv/bin/" fi -${PREFIX}mypy src/httpx ${PREFIX}mypy src/ahttpx -${PREFIX}pytest --cov src/httpx tests +${PREFIX}mypy src/httpx +${PREFIX}pytest tests/test_ahttpx +${PREFIX}pytest --cov src/httpx tests/test_httpx From e4841c10707f45f5486fb1fb392871efc41f74ca Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 26 Feb 2026 17:36:50 +0000 Subject: [PATCH 11/15] Update tests --- scripts/test | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/test b/scripts/test index 6152633..fa7ed83 100755 --- a/scripts/test +++ b/scripts/test @@ -6,6 +6,7 @@ if [ -d 'venv' ] ; then fi ${PREFIX}mypy src/ahttpx -${PREFIX}mypy src/httpx ${PREFIX}pytest tests/test_ahttpx -${PREFIX}pytest --cov src/httpx tests/test_httpx + +${PREFIX}mypy src/httpx +${PREFIX}pytest tests/test_httpx From d67402a4e8b38b7ec65c5e215998686a96da9017 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 26 Feb 2026 17:42:45 +0000 Subject: [PATCH 12/15] Test async --- scripts/test | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/test b/scripts/test index fa7ed83..491c9c1 100755 --- a/scripts/test +++ b/scripts/test @@ -8,5 +8,5 @@ fi ${PREFIX}mypy src/ahttpx ${PREFIX}pytest tests/test_ahttpx -${PREFIX}mypy src/httpx -${PREFIX}pytest tests/test_httpx +# ${PREFIX}mypy src/httpx +# ${PREFIX}pytest tests/test_httpx From 1f1a6f9de5c40cc57a7e18a83a8ef858112ad409 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 27 Feb 2026 14:11:40 +0000 Subject: [PATCH 13/15] Test scripts for both httpx and ahttpx --- .github/workflows/test-suite.yml | 6 ++++-- scripts/{test => test-ahttpx} | 5 +---- scripts/test-httpx | 9 +++++++++ 3 files changed, 14 insertions(+), 6 deletions(-) rename scripts/{test => test-ahttpx} (53%) create mode 100755 scripts/test-httpx diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 8d11a52..3072dff 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -23,5 +23,7 @@ jobs: allow-prereleases: true - name: "Install dependencies" run: "scripts/install" - - name: "Run tests" - run: "scripts/test" + - name: "Run ahttpx tests" + run: "scripts/test-ahttpx" + - name: "Run httpx tests" + run: "scripts/test-httpx" \ No newline at end of file diff --git a/scripts/test b/scripts/test-ahttpx similarity index 53% rename from scripts/test rename to scripts/test-ahttpx index 491c9c1..1201b1b 100755 --- a/scripts/test +++ b/scripts/test-ahttpx @@ -6,7 +6,4 @@ if [ -d 'venv' ] ; then fi ${PREFIX}mypy src/ahttpx -${PREFIX}pytest tests/test_ahttpx - -# ${PREFIX}mypy src/httpx -# ${PREFIX}pytest tests/test_httpx +${PREFIX}pytest --cov src/ahttpx tests/test_ahttpx diff --git a/scripts/test-httpx b/scripts/test-httpx new file mode 100755 index 0000000..e90137f --- /dev/null +++ b/scripts/test-httpx @@ -0,0 +1,9 @@ +#!/bin/sh + +export PREFIX="" +if [ -d 'venv' ] ; then + export PREFIX="venv/bin/" +fi + +${PREFIX}mypy src/httpx +${PREFIX}pytest --cov src/httpx tests/test_httpx From 6fad3bb01ff6f0820b5952e5d6588b763991177a Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 27 Feb 2026 14:20:52 +0000 Subject: [PATCH 14/15] Update test suite --- .github/workflows/test-suite.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 3072dff..4c9ebb4 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -23,7 +23,5 @@ jobs: allow-prereleases: true - name: "Install dependencies" run: "scripts/install" - - name: "Run ahttpx tests" - run: "scripts/test-ahttpx" - name: "Run httpx tests" run: "scripts/test-httpx" \ No newline at end of file From 49b2a1ec6b5e7e3f5bd8c618735ae650626aa68b Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Fri, 27 Feb 2026 14:24:17 +0000 Subject: [PATCH 15/15] Update test suite --- .github/workflows/test-suite.yml | 4 ++-- scripts/{test-ahttpx => test} | 0 scripts/test-httpx | 9 --------- 3 files changed, 2 insertions(+), 11 deletions(-) rename scripts/{test-ahttpx => test} (100%) delete mode 100755 scripts/test-httpx diff --git a/.github/workflows/test-suite.yml b/.github/workflows/test-suite.yml index 4c9ebb4..2ad9102 100644 --- a/.github/workflows/test-suite.yml +++ b/.github/workflows/test-suite.yml @@ -23,5 +23,5 @@ jobs: allow-prereleases: true - name: "Install dependencies" run: "scripts/install" - - name: "Run httpx tests" - run: "scripts/test-httpx" \ No newline at end of file + - name: "Run tests" + run: "scripts/test" \ No newline at end of file diff --git a/scripts/test-ahttpx b/scripts/test similarity index 100% rename from scripts/test-ahttpx rename to scripts/test diff --git a/scripts/test-httpx b/scripts/test-httpx deleted file mode 100755 index e90137f..0000000 --- a/scripts/test-httpx +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh - -export PREFIX="" -if [ -d 'venv' ] ; then - export PREFIX="venv/bin/" -fi - -${PREFIX}mypy src/httpx -${PREFIX}pytest --cov src/httpx tests/test_httpx