From e6b2007f5412baad0d1a67df1b4017919a3a87e7 Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Sun, 1 Mar 2026 20:19:37 -0300 Subject: [PATCH 1/2] feat: Add dynamic port binding to TCPSite (#12167) Co-authored-by: Tom Whittock Co-authored-by: Sam Bull Co-authored-by: Tom Whittock <136440158+twhittock-disguise@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: rodrigo.nogueira Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> (cherry picked from commit b08d909a0cb303498513be6916ea4e8a50792f04) --- CHANGES/10665.feature.rst | 1 + CONTRIBUTORS.txt | 1 + aiohttp/web_runner.py | 33 ++++++++++++++++++++++++++++++--- docs/web_reference.rst | 10 +++++++++- examples/fake_server.py | 12 ++++++------ tests/test_run_app.py | 2 ++ tests/test_web_runner.py | 35 +++++++++++++++++++++++------------ 7 files changed, 72 insertions(+), 22 deletions(-) create mode 100644 CHANGES/10665.feature.rst diff --git a/CHANGES/10665.feature.rst b/CHANGES/10665.feature.rst new file mode 100644 index 00000000000..afb4768c7cf --- /dev/null +++ b/CHANGES/10665.feature.rst @@ -0,0 +1 @@ +Added :py:attr:`~aiohttp.web.TCPSite.port` accessor for dynamic port allocations in :class:`~aiohttp.web.TCPSite` -- by :user:`twhittock-disguise` and :user:`rodrigobnogueira`. diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 845b24c7f29..755bcb7d1aa 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -357,6 +357,7 @@ Thomas Forbes Thomas Grainger Tim Menninger Tolga Tezel +Tom Whittock Tomasz Trebski Toshiaki Tanaka Trevor Gamblin diff --git a/aiohttp/web_runner.py b/aiohttp/web_runner.py index 6ea43c6237c..b2128fa3cb1 100644 --- a/aiohttp/web_runner.py +++ b/aiohttp/web_runner.py @@ -7,8 +7,10 @@ from yarl import URL +from .abc import AbstractAccessLogger from .typedefs import PathLike from .web_app import Application +from .web_log import AccessLogger from .web_server import Server if TYPE_CHECKING: @@ -80,7 +82,7 @@ async def stop(self) -> None: class TCPSite(BaseSite): - __slots__ = ("_host", "_port", "_reuse_address", "_reuse_port") + __slots__ = ("_host", "_port", "_bound_port", "_reuse_address", "_reuse_port") def __init__( self, @@ -104,14 +106,29 @@ def __init__( if port is None: port = 8443 if self._ssl_context else 8080 self._port = port + self._bound_port: int | None = None self._reuse_address = reuse_address self._reuse_port = reuse_port + @property + def port(self) -> int: + """The port the server is listening on. + + If the server hasn't been started yet, this returns the requested port + (which might be 0 for a dynamic port). + After the server starts, it returns the actual bound port. This is + especially useful when port=0 was requested, as it allows retrieving the + dynamically assigned port after the site has started. + """ + if self._bound_port is not None: + return self._bound_port + return self._port + @property def name(self) -> str: scheme = "https" if self._ssl_context else "http" host = "0.0.0.0" if not self._host else self._host - return str(URL.build(scheme=scheme, host=host, port=self._port)) + return str(URL.build(scheme=scheme, host=host, port=self.port)) async def start(self) -> None: await super().start() @@ -127,6 +144,10 @@ async def start(self) -> None: reuse_address=self._reuse_address, reuse_port=self._reuse_port, ) + if self._server.sockets: + self._bound_port = self._server.sockets[0].getsockname()[1] + else: + self._bound_port = self._port class UnixSite(BaseSite): @@ -369,13 +390,19 @@ class AppRunner(BaseRunner): __slots__ = ("_app",) def __init__( - self, app: Application, *, handle_signals: bool = False, **kwargs: Any + self, + app: Application, + *, + handle_signals: bool = False, + access_log_class: type[AbstractAccessLogger] = AccessLogger, + **kwargs: Any, ) -> None: super().__init__(handle_signals=handle_signals, **kwargs) if not isinstance(app, Application): raise TypeError( f"The first argument should be web.Application instance, got {app!r}" ) + self._kwargs["access_log_class"] = access_log_class self._app = app @property diff --git a/docs/web_reference.rst b/docs/web_reference.rst index 0870538b211..a3d9e1b284d 100644 --- a/docs/web_reference.rst +++ b/docs/web_reference.rst @@ -2998,7 +2998,9 @@ application on specific TCP or Unix socket, e.g.:: :param str host: HOST to listen on, all interfaces if ``None`` (default). - :param int port: PORT to listed on, ``8080`` if ``None`` (default). + :param int port: PORT to listen on, ``8080`` if ``None`` (default). + Use ``0`` to let the OS assign a free ephemeral port + (see :attr:`port`). :param float shutdown_timeout: a timeout used for both waiting on pending tasks before application shutdown and for @@ -3026,6 +3028,12 @@ application on specific TCP or Unix socket, e.g.:: this flag when being created. This option is not supported on Windows. + .. attribute:: port + + Read-only. The actual port number the server is bound to, only + guaranteed to be correct after the site has been started. + + .. class:: UnixSite(runner, path, *, \ shutdown_timeout=60.0, ssl_context=None, \ backlog=128) diff --git a/examples/fake_server.py b/examples/fake_server.py index bdfa671036c..5f7add0ac44 100755 --- a/examples/fake_server.py +++ b/examples/fake_server.py @@ -5,7 +5,7 @@ import ssl import aiohttp -from aiohttp import web +from aiohttp import ClientSession, TCPConnector, web from aiohttp.abc import AbstractResolver, ResolveResult from aiohttp.resolver import DefaultResolver from aiohttp.test_utils import unused_port @@ -61,13 +61,13 @@ def __init__(self, *, loop): self.ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) self.ssl_context.load_cert_chain(str(ssl_cert), str(ssl_key)) - async def start(self): - port = unused_port() - self.runner = web.AppRunner(self.app) + async def start(self) -> dict[str, int]: await self.runner.setup() - site = web.TCPSite(self.runner, "127.0.0.1", port, ssl_context=self.ssl_context) + site = web.TCPSite( + self.runner, "127.0.0.1", port=0, ssl_context=self.ssl_context + ) await site.start() - return {"graph.facebook.com": port} + return {"graph.facebook.com": site.port} async def stop(self): await self.runner.cleanup() diff --git a/tests/test_run_app.py b/tests/test_run_app.py index c2ec32b2390..899064aa165 100644 --- a/tests/test_run_app.py +++ b/tests/test_run_app.py @@ -61,8 +61,10 @@ def patched_loop( ) -> Iterator[asyncio.AbstractEventLoop]: server = mock.create_autospec(asyncio.Server, spec_set=True, instance=True) server.wait_closed.return_value = None + server.sockets = [] unix_server = mock.create_autospec(asyncio.Server, spec_set=True, instance=True) unix_server.wait_closed.return_value = None + unix_server.sockets = [] with mock.patch.object( loop, "create_server", autospec=True, spec_set=True, return_value=server ): diff --git a/tests/test_web_runner.py b/tests/test_web_runner.py index 22ce3d00650..bb7ea578c31 100644 --- a/tests/test_web_runner.py +++ b/tests/test_web_runner.py @@ -2,11 +2,13 @@ import platform import signal from typing import Any +from unittest import mock from unittest.mock import patch import pytest from aiohttp import web +from aiohttp.abc import AbstractAccessLogger from aiohttp.test_utils import get_unused_port_socket @@ -167,20 +169,16 @@ async def test_tcpsite_default_host(make_runner: Any) -> None: site = web.TCPSite(runner) assert site.name == "http://0.0.0.0:8080" - calls = [] - - async def mock_create_server(*args, **kwargs): - calls.append((args, kwargs)) - - with patch("asyncio.get_event_loop") as mock_get_loop: - mock_get_loop.return_value.create_server = mock_create_server + m = mock.create_autospec(asyncio.AbstractEventLoop, spec_set=True, instance=True) + m.create_server.return_value = mock.create_autospec(asyncio.Server, spec_set=True) + with mock.patch( + "asyncio.get_event_loop", autospec=True, spec_set=True, return_value=m + ): await site.start() - assert len(calls) == 1 - server, host, port = calls[0][0] - assert server is runner.server - assert host is None - assert port == 8080 + m.create_server.assert_called_once() + args, kwargs = m.create_server.call_args + assert args == (runner.server, None, 8080) async def test_tcpsite_empty_str_host(make_runner: Any) -> None: @@ -242,3 +240,16 @@ async def test_app_handler_args_ceil_threshold(value: Any, expected: Any) -> Non assert rh._timeout_ceil_threshold == expected await runner.cleanup() assert app + + + +async def test_tcpsite_ephemeral_port(make_runner: Any) -> None: + runner = make_runner() + await runner.setup() + site = web.TCPSite(runner, port=0) + assert site.port == 0 + + await site.start() + assert site.port != 0 + assert site.name.startswith("http://0.0.0.0:") + await site.stop() From a059898cfbe7ac001f1bed5e880db8e8b2525dc8 Mon Sep 17 00:00:00 2001 From: "rodrigo.nogueira" Date: Sun, 1 Mar 2026 20:55:38 -0300 Subject: [PATCH 2/2] fix: flake8 unused imports and blank lines --- examples/fake_server.py | 3 +-- tests/test_web_runner.py | 3 --- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/examples/fake_server.py b/examples/fake_server.py index 5f7add0ac44..af6de0903e9 100755 --- a/examples/fake_server.py +++ b/examples/fake_server.py @@ -5,10 +5,9 @@ import ssl import aiohttp -from aiohttp import ClientSession, TCPConnector, web +from aiohttp import web from aiohttp.abc import AbstractResolver, ResolveResult from aiohttp.resolver import DefaultResolver -from aiohttp.test_utils import unused_port class FakeResolver(AbstractResolver): diff --git a/tests/test_web_runner.py b/tests/test_web_runner.py index bb7ea578c31..d9e16a82f39 100644 --- a/tests/test_web_runner.py +++ b/tests/test_web_runner.py @@ -3,12 +3,10 @@ import signal from typing import Any from unittest import mock -from unittest.mock import patch import pytest from aiohttp import web -from aiohttp.abc import AbstractAccessLogger from aiohttp.test_utils import get_unused_port_socket @@ -242,7 +240,6 @@ async def test_app_handler_args_ceil_threshold(value: Any, expected: Any) -> Non assert app - async def test_tcpsite_ephemeral_port(make_runner: Any) -> None: runner = make_runner() await runner.setup()