Skip to content

Commit 7dbaece

Browse files
rodrigobnogueiratwhittock-disguiseDreamsorcererbdracorodrigo.nogueira
authored
[3.14 backport] feat: Add dynamic port binding to TCPSite (#12167) (#12184)
Co-authored-by: Tom Whittock <tom.whittock@disguise.one> Co-authored-by: Sam Bull <git@sambull.org> Co-authored-by: Tom Whittock <136440158+twhittock-disguise@users.noreply.github.com> Co-authored-by: J. Nick Koston <nick+github@koston.org> Co-authored-by: rodrigo.nogueira <rodrigo.nogueira@prf.gov.br>
1 parent 9029e60 commit 7dbaece

7 files changed

Lines changed: 69 additions & 23 deletions

File tree

CHANGES/10665.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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`.

CONTRIBUTORS.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,7 @@ Thomas Forbes
357357
Thomas Grainger
358358
Tim Menninger
359359
Tolga Tezel
360+
Tom Whittock
360361
Tomasz Trebski
361362
Toshiaki Tanaka
362363
Trevor Gamblin

aiohttp/web_runner.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
from yarl import URL
99

10+
from .abc import AbstractAccessLogger
1011
from .typedefs import PathLike
1112
from .web_app import Application
13+
from .web_log import AccessLogger
1214
from .web_server import Server
1315

1416
if TYPE_CHECKING:
@@ -80,7 +82,7 @@ async def stop(self) -> None:
8082

8183

8284
class TCPSite(BaseSite):
83-
__slots__ = ("_host", "_port", "_reuse_address", "_reuse_port")
85+
__slots__ = ("_host", "_port", "_bound_port", "_reuse_address", "_reuse_port")
8486

8587
def __init__(
8688
self,
@@ -104,14 +106,29 @@ def __init__(
104106
if port is None:
105107
port = 8443 if self._ssl_context else 8080
106108
self._port = port
109+
self._bound_port: int | None = None
107110
self._reuse_address = reuse_address
108111
self._reuse_port = reuse_port
109112

113+
@property
114+
def port(self) -> int:
115+
"""The port the server is listening on.
116+
117+
If the server hasn't been started yet, this returns the requested port
118+
(which might be 0 for a dynamic port).
119+
After the server starts, it returns the actual bound port. This is
120+
especially useful when port=0 was requested, as it allows retrieving the
121+
dynamically assigned port after the site has started.
122+
"""
123+
if self._bound_port is not None:
124+
return self._bound_port
125+
return self._port
126+
110127
@property
111128
def name(self) -> str:
112129
scheme = "https" if self._ssl_context else "http"
113130
host = "0.0.0.0" if not self._host else self._host
114-
return str(URL.build(scheme=scheme, host=host, port=self._port))
131+
return str(URL.build(scheme=scheme, host=host, port=self.port))
115132

116133
async def start(self) -> None:
117134
await super().start()
@@ -127,6 +144,10 @@ async def start(self) -> None:
127144
reuse_address=self._reuse_address,
128145
reuse_port=self._reuse_port,
129146
)
147+
if self._server.sockets:
148+
self._bound_port = self._server.sockets[0].getsockname()[1]
149+
else:
150+
self._bound_port = self._port
130151

131152

132153
class UnixSite(BaseSite):
@@ -369,13 +390,19 @@ class AppRunner(BaseRunner):
369390
__slots__ = ("_app",)
370391

371392
def __init__(
372-
self, app: Application, *, handle_signals: bool = False, **kwargs: Any
393+
self,
394+
app: Application,
395+
*,
396+
handle_signals: bool = False,
397+
access_log_class: type[AbstractAccessLogger] = AccessLogger,
398+
**kwargs: Any,
373399
) -> None:
374400
super().__init__(handle_signals=handle_signals, **kwargs)
375401
if not isinstance(app, Application):
376402
raise TypeError(
377403
f"The first argument should be web.Application instance, got {app!r}"
378404
)
405+
self._kwargs["access_log_class"] = access_log_class
379406
self._app = app
380407

381408
@property

docs/web_reference.rst

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2998,7 +2998,9 @@ application on specific TCP or Unix socket, e.g.::
29982998

29992999
:param str host: HOST to listen on, all interfaces if ``None`` (default).
30003000

3001-
:param int port: PORT to listed on, ``8080`` if ``None`` (default).
3001+
:param int port: PORT to listen on, ``8080`` if ``None`` (default).
3002+
Use ``0`` to let the OS assign a free ephemeral port
3003+
(see :attr:`port`).
30023004

30033005
:param float shutdown_timeout: a timeout used for both waiting on pending
30043006
tasks before application shutdown and for
@@ -3026,6 +3028,12 @@ application on specific TCP or Unix socket, e.g.::
30263028
this flag when being created. This option is not
30273029
supported on Windows.
30283030

3031+
.. attribute:: port
3032+
3033+
Read-only. The actual port number the server is bound to, only
3034+
guaranteed to be correct after the site has been started.
3035+
3036+
30293037
.. class:: UnixSite(runner, path, *, \
30303038
shutdown_timeout=60.0, ssl_context=None, \
30313039
backlog=128)

examples/fake_server.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
from aiohttp import web
99
from aiohttp.abc import AbstractResolver, ResolveResult
1010
from aiohttp.resolver import DefaultResolver
11-
from aiohttp.test_utils import unused_port
1211

1312

1413
class FakeResolver(AbstractResolver):
@@ -61,13 +60,13 @@ def __init__(self, *, loop):
6160
self.ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
6261
self.ssl_context.load_cert_chain(str(ssl_cert), str(ssl_key))
6362

64-
async def start(self):
65-
port = unused_port()
66-
self.runner = web.AppRunner(self.app)
63+
async def start(self) -> dict[str, int]:
6764
await self.runner.setup()
68-
site = web.TCPSite(self.runner, "127.0.0.1", port, ssl_context=self.ssl_context)
65+
site = web.TCPSite(
66+
self.runner, "127.0.0.1", port=0, ssl_context=self.ssl_context
67+
)
6968
await site.start()
70-
return {"graph.facebook.com": port}
69+
return {"graph.facebook.com": site.port}
7170

7271
async def stop(self):
7372
await self.runner.cleanup()

tests/test_run_app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,10 @@ def patched_loop(
6161
) -> Iterator[asyncio.AbstractEventLoop]:
6262
server = mock.create_autospec(asyncio.Server, spec_set=True, instance=True)
6363
server.wait_closed.return_value = None
64+
server.sockets = []
6465
unix_server = mock.create_autospec(asyncio.Server, spec_set=True, instance=True)
6566
unix_server.wait_closed.return_value = None
67+
unix_server.sockets = []
6668
with mock.patch.object(
6769
loop, "create_server", autospec=True, spec_set=True, return_value=server
6870
):

tests/test_web_runner.py

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import platform
33
import signal
44
from typing import Any
5-
from unittest.mock import patch
5+
from unittest import mock
66

77
import pytest
88

@@ -167,20 +167,16 @@ async def test_tcpsite_default_host(make_runner: Any) -> None:
167167
site = web.TCPSite(runner)
168168
assert site.name == "http://0.0.0.0:8080"
169169

170-
calls = []
171-
172-
async def mock_create_server(*args, **kwargs):
173-
calls.append((args, kwargs))
174-
175-
with patch("asyncio.get_event_loop") as mock_get_loop:
176-
mock_get_loop.return_value.create_server = mock_create_server
170+
m = mock.create_autospec(asyncio.AbstractEventLoop, spec_set=True, instance=True)
171+
m.create_server.return_value = mock.create_autospec(asyncio.Server, spec_set=True)
172+
with mock.patch(
173+
"asyncio.get_event_loop", autospec=True, spec_set=True, return_value=m
174+
):
177175
await site.start()
178176

179-
assert len(calls) == 1
180-
server, host, port = calls[0][0]
181-
assert server is runner.server
182-
assert host is None
183-
assert port == 8080
177+
m.create_server.assert_called_once()
178+
args, kwargs = m.create_server.call_args
179+
assert args == (runner.server, None, 8080)
184180

185181

186182
async def test_tcpsite_empty_str_host(make_runner: Any) -> None:
@@ -242,3 +238,15 @@ async def test_app_handler_args_ceil_threshold(value: Any, expected: Any) -> Non
242238
assert rh._timeout_ceil_threshold == expected
243239
await runner.cleanup()
244240
assert app
241+
242+
243+
async def test_tcpsite_ephemeral_port(make_runner: Any) -> None:
244+
runner = make_runner()
245+
await runner.setup()
246+
site = web.TCPSite(runner, port=0)
247+
assert site.port == 0
248+
249+
await site.start()
250+
assert site.port != 0
251+
assert site.name.startswith("http://0.0.0.0:")
252+
await site.stop()

0 commit comments

Comments
 (0)