From b08d909a0cb303498513be6916ea4e8a50792f04 Mon Sep 17 00:00:00 2001 From: Rodrigo Nogueira Date: Sun, 1 Mar 2026 20:19:37 -0300 Subject: [PATCH] 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> --- CHANGES/10665.feature.rst | 1 + CONTRIBUTORS.txt | 1 + aiohttp/web_runner.py | 23 +++++++++++++++++++++-- docs/web_reference.rst | 10 +++++++++- examples/fake_server.py | 12 ++++++------ tests/test_run_app.py | 2 ++ tests/test_web_runner.py | 13 +++++++++++++ 7 files changed, 53 insertions(+), 9 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 487691f688f..c3c16f82eee 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -366,6 +366,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 480914d4480..3d2277570aa 100644 --- a/aiohttp/web_runner.py +++ b/aiohttp/web_runner.py @@ -79,7 +79,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, @@ -101,14 +101,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() @@ -124,6 +139,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): diff --git a/docs/web_reference.rst b/docs/web_reference.rst index fe2488c63b8..99318ec90ee 100644 --- a/docs/web_reference.rst +++ b/docs/web_reference.rst @@ -2902,7 +2902,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 @@ -2930,6 +2932,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 13cc2dfe77d..4b72fa61da1 100755 --- a/examples/fake_server.py +++ b/examples/fake_server.py @@ -4,7 +4,7 @@ import socket import ssl -from aiohttp import ClientSession, TCPConnector, test_utils, web +from aiohttp import ClientSession, TCPConnector, web from aiohttp.abc import AbstractResolver, ResolveResult from aiohttp.resolver import DefaultResolver @@ -59,11 +59,12 @@ def __init__(self) -> None: self.ssl_context.load_cert_chain(str(ssl_cert), str(ssl_key)) async def start(self) -> dict[str, int]: - port = test_utils.unused_port() 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) -> None: await self.runner.cleanup() @@ -116,5 +117,4 @@ async def main() -> None: await fake_facebook.stop() -loop = asyncio.get_event_loop() -loop.run_until_complete(main()) +asyncio.run(main()) diff --git a/tests/test_run_app.py b/tests/test_run_app.py index dab36942e49..f865de13a7e 100644 --- a/tests/test_run_app.py +++ b/tests/test_run_app.py @@ -67,8 +67,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 5631deb526a..5b31fb1c8b3 100644 --- a/tests/test_web_runner.py +++ b/tests/test_web_runner.py @@ -281,9 +281,22 @@ async def test_tcpsite_empty_str_host(make_runner: _RunnerMaker) -> None: runner = make_runner() await runner.setup() site = web.TCPSite(runner, host="") + assert site.port == 8080 assert site.name == "http://0.0.0.0:8080" +async def test_tcpsite_ephemeral_port(make_runner: _RunnerMaker) -> 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() + + def test_run_after_asyncio_run() -> None: called = False