From 7e6feca051024a444b9beec5304510f50dbdf798 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sat, 17 Jan 2026 10:31:44 +0000 Subject: [PATCH 01/10] Initial ASGI HTTP implementation --- tornado/asgi.py | 132 ++++++++++++++++++++++++++++++++++++++++++++ tornado/httputil.py | 14 +++++ 2 files changed, 146 insertions(+) create mode 100644 tornado/asgi.py diff --git a/tornado/asgi.py b/tornado/asgi.py new file mode 100644 index 000000000..3582b059e --- /dev/null +++ b/tornado/asgi.py @@ -0,0 +1,132 @@ +from asyncio import create_task, Future +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Optional, Union + +from tornado.httputil import HTTPConnection, HTTPHeaders, RequestStartLine, ResponseStartLine +from tornado.web import Application + +ReceiveCallable = Callable[[], Awaitable[dict]] +SendCallable = Callable[[dict], Awaitable[None]] + +@dataclass +class ASGIHTTPRequestContext: + """To convey connection details to the HTTPServerRequest object""" + protocol: str + address: Optional[tuple] = None + remote_ip: str = "0.0.0.0" + +class ASGIHTTPConnection(HTTPConnection): + """Represents the connection for 1 request/response pair + + This provides the API for sending the response. + """ + def __init__(self, send_cb: SendCallable, context: ASGIHTTPRequestContext, task_holder: set): + self.send_cb = send_cb + self.context = context + self.task_holder = task_holder + self._close_callback = None + + # Various tornado APIs (e.g. RequestHandler.flush()) return a Future which + # application code does not need to await. The operations these represent + # are expected to complete even if the Future is discarded. ASGI is based + # on 'awaitable callables', which do not guarantee this. So we need to hold + # references to tasks until they complete + def _bg_task(self, coro): + task = create_task(coro) + self.task_holder.add(task) + task.add_done_callback(self.task_holder.discard) + return task + + async def _write_headers( + self, + start_line: ResponseStartLine, + headers: HTTPHeaders, + chunk: Optional[bytes] = None, + ): + await self.send_cb({ + "type": "http.response.start", + "status": start_line.code, + "headers": [[k.lower().encode('latin1'), v.encode('latin1')] + for k, v in headers.get_all()] + }) + if chunk is not None: + await self._write(chunk) + + def write_headers( + self, + start_line: Union["RequestStartLine", "ResponseStartLine"], + headers: HTTPHeaders, + chunk: Optional[bytes] = None, + ) -> "Future[None]": + return self._bg_task(self._write_headers(start_line, headers, chunk)) + + async def _write(self, chunk: bytes): + await self.send_cb({ + "type": "http.response.body", + "body": chunk, + "more_body": True + }) + + def write(self, chunk: bytes) -> "Future[None]": + return self._bg_task(self._write(chunk)) + + def finish(self) -> None: + self._bg_task(self.send_cb({ + "type": "http.response.body", + "more_body": False, + })) + + def set_close_callback(self, callback: Optional[Callable[[], None]]): + self._close_callback = callback + + def _on_connection_close(self) -> None: + if self._close_callback is not None: + callback = self._close_callback + self._close_callback = None + callback() + + +class ASGIAdapter: + """Wrap a tornado application object to use with an ASGI server""" + def __init__(self, application: Application): + self.application = application + self.task_holder = set() + + async def __call__(self, scope, receive: ReceiveCallable, send: SendCallable): + if scope['type'] == 'http': + return await self.http_scope(scope, receive, send) + raise KeyError(scope['type']) + + async def http_scope(self, scope, receive: ReceiveCallable, send: SendCallable): + """Handles one HTTP request""" + ctx = ASGIHTTPRequestContext(scope["scheme"]) + if client_addr := scope.get("client", None): + ctx.address = tuple(client_addr) + ctx.remote_ip = client_addr[0] + + conn = ASGIHTTPConnection(send, ctx, self.task_holder) + req_start_line = RequestStartLine( + scope['method'], scope['path'], scope['http_version'] + ) + req_headers = HTTPHeaders() + for k, v in scope['headers']: + req_headers.add(k.decode('latin1'), v.decode('latin1')) + msg_delegate = self.application.start_request(None, conn) + fut = msg_delegate.headers_received(req_start_line, req_headers) + if fut is not None: + await fut + + while True: + event = await receive() + if event["type"] == "http.request": + if chunk := event.get("body", b""): + if (fut := msg_delegate.data_received(chunk)) is not None: + await fut + if not event.get("more_body", False): + msg_delegate.finish() + break + elif event["type"] == "http.disconnect": + msg_delegate.on_connection_close() + conn._on_connection_close() + break diff --git a/tornado/httputil.py b/tornado/httputil.py index 74dfb87f1..10f59dddb 100644 --- a/tornado/httputil.py +++ b/tornado/httputil.py @@ -778,6 +778,20 @@ def finish(self) -> None: """Indicates that the last body data has been written.""" raise NotImplementedError() + def set_close_callback(self, callback: Optional[collections.abc.Callable[[], None]]) -> None: + """Sets a callback that will be run when the connection is closed. + + Note that this callback is slightly different from + `.HTTPMessageDelegate.on_connection_close`: The + `.HTTPMessageDelegate` method is called when the connection is + closed while receiving a message. This callback is used when + there is not an active delegate (for example, on the server + side this callback is used if the client closes the connection + after sending its request but before receiving all the + response. + """ + raise NotImplementedError() + def url_concat( url: str, From 098496cbbbf08d7be6454dae3a905e754cdfcdc4 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sat, 17 Jan 2026 11:07:01 +0000 Subject: [PATCH 02/10] Query string needs to be in request start line --- tornado/asgi.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tornado/asgi.py b/tornado/asgi.py index 3582b059e..ea35e4666 100644 --- a/tornado/asgi.py +++ b/tornado/asgi.py @@ -106,8 +106,11 @@ async def http_scope(self, scope, receive: ReceiveCallable, send: SendCallable): ctx.remote_ip = client_addr[0] conn = ASGIHTTPConnection(send, ctx, self.task_holder) + req_target = scope['path'] + if qs := scope['query_string']: + req_target += '?' + qs.decode('latin1') req_start_line = RequestStartLine( - scope['method'], scope['path'], scope['http_version'] + scope['method'], req_target, scope['http_version'] ) req_headers = HTTPHeaders() for k, v in scope['headers']: From 8a3a1bc49832f1822d3e4bcf74cf3ac311b6fec9 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sat, 17 Jan 2026 11:08:25 +0000 Subject: [PATCH 03/10] Fix code style --- tornado/asgi.py | 68 ++++++++++++++++++++++++++++----------------- tornado/httputil.py | 4 ++- 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/tornado/asgi.py b/tornado/asgi.py index ea35e4666..75cca5f8f 100644 --- a/tornado/asgi.py +++ b/tornado/asgi.py @@ -3,25 +3,36 @@ from dataclasses import dataclass from typing import Optional, Union -from tornado.httputil import HTTPConnection, HTTPHeaders, RequestStartLine, ResponseStartLine +from tornado.httputil import ( + HTTPConnection, + HTTPHeaders, + RequestStartLine, + ResponseStartLine, +) from tornado.web import Application ReceiveCallable = Callable[[], Awaitable[dict]] SendCallable = Callable[[dict], Awaitable[None]] + @dataclass class ASGIHTTPRequestContext: """To convey connection details to the HTTPServerRequest object""" + protocol: str address: Optional[tuple] = None remote_ip: str = "0.0.0.0" + class ASGIHTTPConnection(HTTPConnection): """Represents the connection for 1 request/response pair This provides the API for sending the response. """ - def __init__(self, send_cb: SendCallable, context: ASGIHTTPRequestContext, task_holder: set): + + def __init__( + self, send_cb: SendCallable, context: ASGIHTTPRequestContext, task_holder: set + ): self.send_cb = send_cb self.context = context self.task_holder = task_holder @@ -44,12 +55,16 @@ async def _write_headers( headers: HTTPHeaders, chunk: Optional[bytes] = None, ): - await self.send_cb({ - "type": "http.response.start", - "status": start_line.code, - "headers": [[k.lower().encode('latin1'), v.encode('latin1')] - for k, v in headers.get_all()] - }) + await self.send_cb( + { + "type": "http.response.start", + "status": start_line.code, + "headers": [ + [k.lower().encode("latin1"), v.encode("latin1")] + for k, v in headers.get_all() + ], + } + ) if chunk is not None: await self._write(chunk) @@ -62,20 +77,22 @@ def write_headers( return self._bg_task(self._write_headers(start_line, headers, chunk)) async def _write(self, chunk: bytes): - await self.send_cb({ - "type": "http.response.body", - "body": chunk, - "more_body": True - }) + await self.send_cb( + {"type": "http.response.body", "body": chunk, "more_body": True} + ) def write(self, chunk: bytes) -> "Future[None]": return self._bg_task(self._write(chunk)) def finish(self) -> None: - self._bg_task(self.send_cb({ - "type": "http.response.body", - "more_body": False, - })) + self._bg_task( + self.send_cb( + { + "type": "http.response.body", + "more_body": False, + } + ) + ) def set_close_callback(self, callback: Optional[Callable[[], None]]): self._close_callback = callback @@ -89,14 +106,15 @@ def _on_connection_close(self) -> None: class ASGIAdapter: """Wrap a tornado application object to use with an ASGI server""" + def __init__(self, application: Application): self.application = application self.task_holder = set() async def __call__(self, scope, receive: ReceiveCallable, send: SendCallable): - if scope['type'] == 'http': + if scope["type"] == "http": return await self.http_scope(scope, receive, send) - raise KeyError(scope['type']) + raise KeyError(scope["type"]) async def http_scope(self, scope, receive: ReceiveCallable, send: SendCallable): """Handles one HTTP request""" @@ -106,15 +124,15 @@ async def http_scope(self, scope, receive: ReceiveCallable, send: SendCallable): ctx.remote_ip = client_addr[0] conn = ASGIHTTPConnection(send, ctx, self.task_holder) - req_target = scope['path'] - if qs := scope['query_string']: - req_target += '?' + qs.decode('latin1') + req_target = scope["path"] + if qs := scope["query_string"]: + req_target += "?" + qs.decode("latin1") req_start_line = RequestStartLine( - scope['method'], req_target, scope['http_version'] + scope["method"], req_target, scope["http_version"] ) req_headers = HTTPHeaders() - for k, v in scope['headers']: - req_headers.add(k.decode('latin1'), v.decode('latin1')) + for k, v in scope["headers"]: + req_headers.add(k.decode("latin1"), v.decode("latin1")) msg_delegate = self.application.start_request(None, conn) fut = msg_delegate.headers_received(req_start_line, req_headers) if fut is not None: diff --git a/tornado/httputil.py b/tornado/httputil.py index 10f59dddb..1224be92a 100644 --- a/tornado/httputil.py +++ b/tornado/httputil.py @@ -778,7 +778,9 @@ def finish(self) -> None: """Indicates that the last body data has been written.""" raise NotImplementedError() - def set_close_callback(self, callback: Optional[collections.abc.Callable[[], None]]) -> None: + def set_close_callback( + self, callback: Optional[collections.abc.Callable[[], None]] + ) -> None: """Sets a callback that will be run when the connection is closed. Note that this callback is slightly different from From f0ee5172e30e8f80e182a7e91fc52ef989b0a3a2 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sat, 17 Jan 2026 11:28:44 +0000 Subject: [PATCH 04/10] Make mypy happy --- tornado/asgi.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/tornado/asgi.py b/tornado/asgi.py index 75cca5f8f..e5028e810 100644 --- a/tornado/asgi.py +++ b/tornado/asgi.py @@ -1,4 +1,4 @@ -from asyncio import create_task, Future +from asyncio import create_task, Future, Task from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Optional, Union @@ -36,14 +36,14 @@ def __init__( self.send_cb = send_cb self.context = context self.task_holder = task_holder - self._close_callback = None + self._close_callback: Callable[[], None] | None = None # Various tornado APIs (e.g. RequestHandler.flush()) return a Future which # application code does not need to await. The operations these represent # are expected to complete even if the Future is discarded. ASGI is based # on 'awaitable callables', which do not guarantee this. So we need to hold # references to tasks until they complete - def _bg_task(self, coro): + def _bg_task(self, coro) -> Future: # type: ignore task = create_task(coro) self.task_holder.add(task) task.add_done_callback(self.task_holder.discard) @@ -51,10 +51,11 @@ def _bg_task(self, coro): async def _write_headers( self, - start_line: ResponseStartLine, + start_line: Union["RequestStartLine", "ResponseStartLine"], headers: HTTPHeaders, chunk: Optional[bytes] = None, - ): + ) -> None: + assert isinstance(start_line, ResponseStartLine) await self.send_cb( { "type": "http.response.start", @@ -76,7 +77,7 @@ def write_headers( ) -> "Future[None]": return self._bg_task(self._write_headers(start_line, headers, chunk)) - async def _write(self, chunk: bytes): + async def _write(self, chunk: bytes) -> None: await self.send_cb( {"type": "http.response.body", "body": chunk, "more_body": True} ) @@ -94,7 +95,7 @@ def finish(self) -> None: ) ) - def set_close_callback(self, callback: Optional[Callable[[], None]]): + def set_close_callback(self, callback: Optional[Callable[[], None]]) -> None: self._close_callback = callback def _on_connection_close(self) -> None: @@ -109,14 +110,18 @@ class ASGIAdapter: def __init__(self, application: Application): self.application = application - self.task_holder = set() + self.task_holder: set[Task] = set() - async def __call__(self, scope, receive: ReceiveCallable, send: SendCallable): + async def __call__( + self, scope: dict, receive: ReceiveCallable, send: SendCallable + ) -> None: if scope["type"] == "http": return await self.http_scope(scope, receive, send) raise KeyError(scope["type"]) - async def http_scope(self, scope, receive: ReceiveCallable, send: SendCallable): + async def http_scope( + self, scope: dict, receive: ReceiveCallable, send: SendCallable + ) -> None: """Handles one HTTP request""" ctx = ASGIHTTPRequestContext(scope["scheme"]) if client_addr := scope.get("client", None): From 8b5add89b3782999da80fc33240aa95e8ff65a43 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sat, 17 Jan 2026 15:40:00 +0000 Subject: [PATCH 05/10] Add first test of ASGI API --- tornado/asgi.py | 22 +++++++++++++++------- tornado/test/asgi_test.py | 30 ++++++++++++++++++++++++++++++ tornado/test/runtests.py | 1 + 3 files changed, 46 insertions(+), 7 deletions(-) create mode 100644 tornado/test/asgi_test.py diff --git a/tornado/asgi.py b/tornado/asgi.py index e5028e810..ae8f669e5 100644 --- a/tornado/asgi.py +++ b/tornado/asgi.py @@ -1,4 +1,4 @@ -from asyncio import create_task, Future, Task +from asyncio import create_task, Future, Task, wait from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Optional, Union @@ -30,13 +30,12 @@ class ASGIHTTPConnection(HTTPConnection): This provides the API for sending the response. """ - def __init__( - self, send_cb: SendCallable, context: ASGIHTTPRequestContext, task_holder: set - ): + def __init__(self, send_cb: SendCallable, context: ASGIHTTPRequestContext): self.send_cb = send_cb self.context = context - self.task_holder = task_holder + self.task_holder: set[Task] = set() self._close_callback: Callable[[], None] | None = None + self._request_finished: Future[None] = Future() # Various tornado APIs (e.g. RequestHandler.flush()) return a Future which # application code does not need to await. The operations these represent @@ -90,10 +89,12 @@ def finish(self) -> None: self.send_cb( { "type": "http.response.body", + "body": b"", "more_body": False, } ) ) + self._request_finished.set_result(None) def set_close_callback(self, callback: Optional[Callable[[], None]]) -> None: self._close_callback = callback @@ -103,6 +104,12 @@ def _on_connection_close(self) -> None: callback = self._close_callback self._close_callback = None callback() + self._request_finished.set_result(None) + + async def wait_finish(self) -> None: + """For the ASGI interface: wait for all input & output to finish""" + await self._request_finished + await wait(self.task_holder) class ASGIAdapter: @@ -110,7 +117,6 @@ class ASGIAdapter: def __init__(self, application: Application): self.application = application - self.task_holder: set[Task] = set() async def __call__( self, scope: dict, receive: ReceiveCallable, send: SendCallable @@ -128,7 +134,7 @@ async def http_scope( ctx.address = tuple(client_addr) ctx.remote_ip = client_addr[0] - conn = ASGIHTTPConnection(send, ctx, self.task_holder) + conn = ASGIHTTPConnection(send, ctx) req_target = scope["path"] if qs := scope["query_string"]: req_target += "?" + qs.decode("latin1") @@ -156,3 +162,5 @@ async def http_scope( msg_delegate.on_connection_close() conn._on_connection_close() break + + await conn.wait_finish() diff --git a/tornado/test/asgi_test.py b/tornado/test/asgi_test.py new file mode 100644 index 000000000..19da5a96c --- /dev/null +++ b/tornado/test/asgi_test.py @@ -0,0 +1,30 @@ +from async_asgi_testclient import TestClient + +from tornado.asgi import ASGIAdapter +from tornado.web import Application, RequestHandler +from tornado.testing import AsyncTestCase, gen_test + + +class BasicHandler(RequestHandler): + def get(self): + name = self.get_argument("name", "world") + self.write(f"Hello, {name}") + + +class AsyncASGITestCase(AsyncTestCase): + def setUp(self) -> None: + super().setUp() + self.asgi_app = ASGIAdapter( + Application( + [ + (r"/", BasicHandler), + ] + ) + ) + + @gen_test(timeout=None) + async def test_basic_request(self): + client = TestClient(self.asgi_app) + resp = await client.get("/?name=foo") + assert resp.status_code == 200 + assert resp.text == "Hello, foo" diff --git a/tornado/test/runtests.py b/tornado/test/runtests.py index d7eb51f9a..761cb9311 100644 --- a/tornado/test/runtests.py +++ b/tornado/test/runtests.py @@ -20,6 +20,7 @@ "tornado.httputil.doctests", "tornado.iostream.doctests", "tornado.util.doctests", + "tornado.test.asgi_test", "tornado.test.asyncio_test", "tornado.test.auth_test", "tornado.test.autoreload_test", From 61895ddb9cf31f3efb0edd034db47ef6e1fc957c Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sat, 17 Jan 2026 15:54:32 +0000 Subject: [PATCH 06/10] Some more tests for basic request details --- tornado/test/asgi_test.py | 52 +++++++++++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/tornado/test/asgi_test.py b/tornado/test/asgi_test.py index 19da5a96c..26867bf7b 100644 --- a/tornado/test/asgi_test.py +++ b/tornado/test/asgi_test.py @@ -11,20 +11,56 @@ def get(self): self.write(f"Hello, {name}") +class InspectHandler(RequestHandler): + def make_response(self, path_var): + # Send the response as JSON + self.finish( + { + "method": self.request.method, + "path": self.request.path, + "path_var": path_var, + "query_params": { + k: self.get_query_arguments(k) for k in self.request.query_arguments + }, + "body": self.request.body.decode("latin1"), + } + ) + + def get(self, path_var): + return self.make_response(path_var) + + def post(self, path_var): + return self.make_response(path_var) + + class AsyncASGITestCase(AsyncTestCase): def setUp(self) -> None: super().setUp() self.asgi_app = ASGIAdapter( - Application( - [ - (r"/", BasicHandler), - ] - ) + Application([(r"/", BasicHandler), (r"/inspect(/.*)", InspectHandler)]) ) + self.client = TestClient(self.asgi_app) - @gen_test(timeout=None) + @gen_test(timeout=10) async def test_basic_request(self): - client = TestClient(self.asgi_app) - resp = await client.get("/?name=foo") + resp = await self.client.get("/?name=foo") assert resp.status_code == 200 assert resp.text == "Hello, foo" + + @gen_test(timeout=10) + async def test_get_request_details(self): + resp = await self.client.get("/inspect/foo/?bar=baz") + d = resp.json() + assert d["method"] == "GET" + assert d["path"] == "/inspect/foo/" + assert d["query_params"] == {"bar": ["baz"]} + assert d["body"] == "" + + @gen_test(timeout=10) + async def test_post_request_details(self): + resp = await self.client.post("/inspect/foo/?bar=baz", data=b"123") + d = resp.json() + assert d["method"] == "POST" + assert d["path"] == "/inspect/foo/" + assert d["query_params"] == {"bar": ["baz"]} + assert d["body"] == "123" From 002ad9edc874f8c43b4103e786a951a9e2160dd0 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sat, 17 Jan 2026 17:28:30 +0000 Subject: [PATCH 07/10] Add async-asgi-testclient to requirements --- requirements.in | 1 + requirements.txt | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/requirements.in b/requirements.in index 417afeb5d..00920aed4 100644 --- a/requirements.in +++ b/requirements.in @@ -1,3 +1,4 @@ +async-asgi-testclient black flake8 mypy>=0.941 diff --git a/requirements.txt b/requirements.txt index e496bc72e..93db5afc9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,13 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.14 # by the following command: # # pip-compile # alabaster==1.0.0 # via sphinx +async-asgi-testclient==1.4.11 + # via -r requirements.in babel==2.17.0 # via sphinx black==25.1.0 @@ -48,6 +50,8 @@ markupsafe==3.0.2 # via jinja2 mccabe==0.7.0 # via flake8 +multidict==6.7.0 + # via async-asgi-testclient mypy==1.15.0 # via -r requirements.in mypy-extensions==1.1.0 @@ -85,7 +89,9 @@ pyproject-hooks==1.2.0 # build # pip-tools requests==2.32.4 - # via sphinx + # via + # async-asgi-testclient + # sphinx roman-numerals-py==3.1.0 # via sphinx snowballstemmer==3.0.1 From 4777fc85c45e35b9c90c0b76aa713bdf8ee66a78 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sat, 17 Jan 2026 17:34:53 +0000 Subject: [PATCH 08/10] ignore lack of typing info for async-asgi-testclient --- tornado/test/asgi_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tornado/test/asgi_test.py b/tornado/test/asgi_test.py index 26867bf7b..045e4fb53 100644 --- a/tornado/test/asgi_test.py +++ b/tornado/test/asgi_test.py @@ -1,4 +1,4 @@ -from async_asgi_testclient import TestClient +from async_asgi_testclient import TestClient # type: ignore from tornado.asgi import ASGIAdapter from tornado.web import Application, RequestHandler From 8ea5b353403b7e458905253f3d5519dd472e9743 Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sat, 17 Jan 2026 17:48:13 +0000 Subject: [PATCH 09/10] Install async-asgi-testclient for full tests --- tornado/test/asgi_test.py | 12 ++++++++++-- tox.ini | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/tornado/test/asgi_test.py b/tornado/test/asgi_test.py index 045e4fb53..5a6f314e2 100644 --- a/tornado/test/asgi_test.py +++ b/tornado/test/asgi_test.py @@ -1,4 +1,9 @@ -from async_asgi_testclient import TestClient # type: ignore +import unittest + +try: + import async_asgi_testclient # type: ignore +except ImportError: + async_asgi_testclient = None from tornado.asgi import ASGIAdapter from tornado.web import Application, RequestHandler @@ -33,13 +38,16 @@ def post(self, path_var): return self.make_response(path_var) +@unittest.skipIf( + async_asgi_testclient is None, "async_asgi_testclient module not present" +) class AsyncASGITestCase(AsyncTestCase): def setUp(self) -> None: super().setUp() self.asgi_app = ASGIAdapter( Application([(r"/", BasicHandler), (r"/inspect(/.*)", InspectHandler)]) ) - self.client = TestClient(self.asgi_app) + self.client = async_asgi_testclient.TestClient(self.asgi_app) @gen_test(timeout=10) async def test_basic_request(self): diff --git a/tox.ini b/tox.ini index db0a4b604..e7496947f 100644 --- a/tox.ini +++ b/tox.ini @@ -50,6 +50,7 @@ deps = # And since CaresResolver is deprecated, I do not expect to fix it, so just # pin the previous version. (This should really be in requirements.{in,txt} instead) full: pycares<5 + full: async-asgi-testclient docs: -r{toxinidir}/requirements.txt lint: -r{toxinidir}/requirements.txt From 3bd3fc017bd0b540f38514be7055dea6ef56be1b Mon Sep 17 00:00:00 2001 From: Thomas Kluyver Date: Sat, 17 Jan 2026 18:12:49 +0000 Subject: [PATCH 10/10] Factor out code converting start line & headers --- tornado/asgi.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/tornado/asgi.py b/tornado/asgi.py index ae8f669e5..1d11775e2 100644 --- a/tornado/asgi.py +++ b/tornado/asgi.py @@ -135,18 +135,9 @@ async def http_scope( ctx.remote_ip = client_addr[0] conn = ASGIHTTPConnection(send, ctx) - req_target = scope["path"] - if qs := scope["query_string"]: - req_target += "?" + qs.decode("latin1") - req_start_line = RequestStartLine( - scope["method"], req_target, scope["http_version"] - ) - req_headers = HTTPHeaders() - for k, v in scope["headers"]: - req_headers.add(k.decode("latin1"), v.decode("latin1")) msg_delegate = self.application.start_request(None, conn) - fut = msg_delegate.headers_received(req_start_line, req_headers) - if fut is not None: + start_line, req_headers = self._http_convert_req(scope) + if (fut := msg_delegate.headers_received(start_line, req_headers)) is not None: await fut while True: @@ -164,3 +155,17 @@ async def http_scope( break await conn.wait_finish() + + @staticmethod + def _http_convert_req(scope: dict) -> tuple[RequestStartLine, HTTPHeaders]: + req_target = scope["path"] + if qs := scope["query_string"]: + req_target += "?" + qs.decode("latin1") + req_start_line = RequestStartLine( + scope["method"], req_target, scope["http_version"] + ) + req_headers = HTTPHeaders() + for k, v in scope["headers"]: + req_headers.add(k.decode("latin1"), v.decode("latin1")) + + return req_start_line, req_headers