Skip to content

Commit b2ddca1

Browse files
authored
[PR #11634/cde03b9 backport][3.13] Fix blocking I/O to load netrc when creating requests (#11680)
1 parent e618dcb commit b2ddca1

File tree

7 files changed

+233
-66
lines changed

7 files changed

+233
-66
lines changed

CHANGES/11634.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed blocking I/O in the event loop when using netrc authentication by moving netrc file lookup to an executor -- by :user:`bdraco`.

aiohttp/client.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,9 @@
9898
EMPTY_BODY_METHODS,
9999
BasicAuth,
100100
TimeoutHandle,
101+
basicauth_from_netrc,
101102
get_env_proxy_for_url,
103+
netrc_from_env,
102104
sentinel,
103105
strip_auth_from_url,
104106
)
@@ -657,6 +659,20 @@ async def _request(
657659
)
658660
):
659661
auth = self._default_auth
662+
663+
# Try netrc if auth is still None and trust_env is enabled.
664+
# Only check if NETRC environment variable is set to avoid
665+
# creating an expensive executor job unnecessarily.
666+
if (
667+
auth is None
668+
and self._trust_env
669+
and url.host is not None
670+
and os.environ.get("NETRC")
671+
):
672+
auth = await self._loop.run_in_executor(
673+
None, self._get_netrc_auth, url.host
674+
)
675+
660676
# It would be confusing if we support explicit
661677
# Authorization header with auth argument
662678
if (
@@ -1211,6 +1227,19 @@ def _prepare_headers(self, headers: Optional[LooseHeaders]) -> "CIMultiDict[str]
12111227
added_names.add(key)
12121228
return result
12131229

1230+
def _get_netrc_auth(self, host: str) -> Optional[BasicAuth]:
1231+
"""
1232+
Get auth from netrc for the given host.
1233+
1234+
This method is designed to be called in an executor to avoid
1235+
blocking I/O in the event loop.
1236+
"""
1237+
netrc_obj = netrc_from_env()
1238+
try:
1239+
return basicauth_from_netrc(netrc_obj, host)
1240+
except LookupError:
1241+
return None
1242+
12141243
if sys.version_info >= (3, 11) and TYPE_CHECKING:
12151244

12161245
def get(

aiohttp/client_reqrep.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,6 @@
5353
BasicAuth,
5454
HeadersMixin,
5555
TimerNoop,
56-
basicauth_from_netrc,
57-
netrc_from_env,
5856
noop,
5957
reify,
6058
sentinel,
@@ -1164,10 +1162,6 @@ def update_auth(self, auth: Optional[BasicAuth], trust_env: bool = False) -> Non
11641162
"""Set basic auth."""
11651163
if auth is None:
11661164
auth = self.auth
1167-
if auth is None and trust_env and self.url.host is not None:
1168-
netrc_obj = netrc_from_env()
1169-
with contextlib.suppress(LookupError):
1170-
auth = basicauth_from_netrc(netrc_obj, self.url.host)
11711165
if auth is None:
11721166
return
11731167

tests/conftest.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,6 @@ def blockbuster(request: pytest.FixtureRequest) -> Iterator[None]:
6969
with blockbuster_ctx(
7070
"aiohttp", excluded_modules=["aiohttp.pytest_plugin", "aiohttp.test_utils"]
7171
) as bb:
72-
# TODO: Fix blocking call in ClientRequest's constructor.
73-
# https://github.com/aio-libs/aiohttp/issues/10435
74-
for func in ["io.TextIOWrapper.read", "os.stat"]:
75-
bb.functions[func].can_block_in("aiohttp/client_reqrep.py", "update_auth")
7672
for func in [
7773
"os.getcwd",
7874
"os.readlink",
@@ -285,7 +281,35 @@ def netrc_contents(
285281

286282

287283
@pytest.fixture
288-
def start_connection():
284+
def netrc_default_contents(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
285+
"""Create a temporary netrc file with default test credentials and set NETRC env var."""
286+
netrc_file = tmp_path / ".netrc"
287+
netrc_file.write_text("default login netrc_user password netrc_pass\n")
288+
289+
monkeypatch.setenv("NETRC", str(netrc_file))
290+
291+
return netrc_file
292+
293+
294+
@pytest.fixture
295+
def no_netrc(monkeypatch: pytest.MonkeyPatch) -> None:
296+
"""Ensure NETRC environment variable is not set."""
297+
monkeypatch.delenv("NETRC", raising=False)
298+
299+
300+
@pytest.fixture
301+
def netrc_other_host(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
302+
"""Create a temporary netrc file with credentials for a different host and set NETRC env var."""
303+
netrc_file = tmp_path / ".netrc"
304+
netrc_file.write_text("machine other.example.com login user password pass\n")
305+
306+
monkeypatch.setenv("NETRC", str(netrc_file))
307+
308+
return netrc_file
309+
310+
311+
@pytest.fixture
312+
def start_connection() -> Iterator[mock.Mock]:
289313
with mock.patch(
290314
"aiohttp.connector.aiohappyeyeballs.start_connection",
291315
autospec=True,

tests/test_client_functional.py

Lines changed: 93 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,25 @@ def fname(here):
7777
return here / "conftest.py"
7878

7979

80-
async def test_keepalive_two_requests_success(aiohttp_client) -> None:
81-
async def handler(request):
80+
@pytest.fixture
81+
def headers_echo_client(
82+
aiohttp_client: AiohttpClient,
83+
) -> Callable[..., Awaitable[TestClient[web.Request, web.Application]]]:
84+
"""Create a client with an app that echoes request headers as JSON."""
85+
86+
async def factory(**kwargs: Any) -> TestClient[web.Request, web.Application]:
87+
async def handler(request: web.Request) -> web.Response:
88+
return web.json_response({"headers": dict(request.headers)})
89+
90+
app = web.Application()
91+
app.router.add_get("/", handler)
92+
return await aiohttp_client(app, **kwargs)
93+
94+
return factory
95+
96+
97+
async def test_keepalive_two_requests_success(aiohttp_client: AiohttpClient) -> None:
98+
async def handler(request: web.Request) -> web.Response:
8299
body = await request.read()
83100
assert b"" == body
84101
return web.Response(body=b"OK")
@@ -3712,29 +3729,25 @@ async def handler(request):
37123729
assert not ctx._coro.cr_running
37133730

37143731

3715-
async def test_session_auth(aiohttp_client) -> None:
3716-
async def handler(request):
3717-
return web.json_response({"headers": dict(request.headers)})
3718-
3719-
app = web.Application()
3720-
app.router.add_get("/", handler)
3721-
3722-
client = await aiohttp_client(app, auth=aiohttp.BasicAuth("login", "pass"))
3732+
async def test_session_auth(
3733+
headers_echo_client: Callable[
3734+
..., Awaitable[TestClient[web.Request, web.Application]]
3735+
],
3736+
) -> None:
3737+
client = await headers_echo_client(auth=aiohttp.BasicAuth("login", "pass"))
37233738

37243739
r = await client.get("/")
37253740
assert r.status == 200
37263741
content = await r.json()
37273742
assert content["headers"]["Authorization"] == "Basic bG9naW46cGFzcw=="
37283743

37293744

3730-
async def test_session_auth_override(aiohttp_client) -> None:
3731-
async def handler(request):
3732-
return web.json_response({"headers": dict(request.headers)})
3733-
3734-
app = web.Application()
3735-
app.router.add_get("/", handler)
3736-
3737-
client = await aiohttp_client(app, auth=aiohttp.BasicAuth("login", "pass"))
3745+
async def test_session_auth_override(
3746+
headers_echo_client: Callable[
3747+
..., Awaitable[TestClient[web.Request, web.Application]]
3748+
],
3749+
) -> None:
3750+
client = await headers_echo_client(auth=aiohttp.BasicAuth("login", "pass"))
37383751

37393752
r = await client.get("/", auth=aiohttp.BasicAuth("other_login", "pass"))
37403753
assert r.status == 200
@@ -3756,30 +3769,77 @@ async def handler(request):
37563769
await client.get("/", headers=headers)
37573770

37583771

3759-
async def test_session_headers(aiohttp_client) -> None:
3760-
async def handler(request):
3761-
return web.json_response({"headers": dict(request.headers)})
3772+
@pytest.mark.usefixtures("netrc_default_contents")
3773+
async def test_netrc_auth_from_env( # type: ignore[misc]
3774+
headers_echo_client: Callable[
3775+
..., Awaitable[TestClient[web.Request, web.Application]]
3776+
],
3777+
) -> None:
3778+
"""Test that netrc authentication works when NETRC env var is set and trust_env=True."""
3779+
client = await headers_echo_client(trust_env=True)
3780+
async with client.get("/") as r:
3781+
assert r.status == 200
3782+
content = await r.json()
3783+
# Base64 encoded "netrc_user:netrc_pass" is "bmV0cmNfdXNlcjpuZXRyY19wYXNz"
3784+
assert content["headers"]["Authorization"] == "Basic bmV0cmNfdXNlcjpuZXRyY19wYXNz"
37623785

3763-
app = web.Application()
3764-
app.router.add_get("/", handler)
37653786

3766-
client = await aiohttp_client(app, headers={"X-Real-IP": "192.168.0.1"})
3787+
@pytest.mark.usefixtures("no_netrc")
3788+
async def test_netrc_auth_skipped_without_env_var( # type: ignore[misc]
3789+
headers_echo_client: Callable[
3790+
..., Awaitable[TestClient[web.Request, web.Application]]
3791+
],
3792+
) -> None:
3793+
"""Test that netrc authentication is skipped when NETRC env var is not set."""
3794+
client = await headers_echo_client(trust_env=True)
3795+
async with client.get("/") as r:
3796+
assert r.status == 200
3797+
content = await r.json()
3798+
# No Authorization header should be present
3799+
assert "Authorization" not in content["headers"]
3800+
3801+
3802+
@pytest.mark.usefixtures("netrc_default_contents")
3803+
async def test_netrc_auth_overridden_by_explicit_auth( # type: ignore[misc]
3804+
headers_echo_client: Callable[
3805+
..., Awaitable[TestClient[web.Request, web.Application]]
3806+
],
3807+
) -> None:
3808+
"""Test that explicit auth parameter overrides netrc authentication."""
3809+
client = await headers_echo_client(trust_env=True)
3810+
# Make request with explicit auth (should override netrc)
3811+
async with client.get(
3812+
"/", auth=aiohttp.BasicAuth("explicit_user", "explicit_pass")
3813+
) as r:
3814+
assert r.status == 200
3815+
content = await r.json()
3816+
# Base64 encoded "explicit_user:explicit_pass" is "ZXhwbGljaXRfdXNlcjpleHBsaWNpdF9wYXNz"
3817+
assert (
3818+
content["headers"]["Authorization"]
3819+
== "Basic ZXhwbGljaXRfdXNlcjpleHBsaWNpdF9wYXNz"
3820+
)
3821+
3822+
3823+
async def test_session_headers(
3824+
headers_echo_client: Callable[
3825+
..., Awaitable[TestClient[web.Request, web.Application]]
3826+
],
3827+
) -> None:
3828+
client = await headers_echo_client(headers={"X-Real-IP": "192.168.0.1"})
37673829

37683830
r = await client.get("/")
37693831
assert r.status == 200
37703832
content = await r.json()
37713833
assert content["headers"]["X-Real-IP"] == "192.168.0.1"
37723834

37733835

3774-
async def test_session_headers_merge(aiohttp_client) -> None:
3775-
async def handler(request):
3776-
return web.json_response({"headers": dict(request.headers)})
3777-
3778-
app = web.Application()
3779-
app.router.add_get("/", handler)
3780-
3781-
client = await aiohttp_client(
3782-
app, headers=[("X-Real-IP", "192.168.0.1"), ("X-Sent-By", "requests")]
3836+
async def test_session_headers_merge(
3837+
headers_echo_client: Callable[
3838+
..., Awaitable[TestClient[web.Request, web.Application]]
3839+
],
3840+
) -> None:
3841+
client = await headers_echo_client(
3842+
headers=[("X-Real-IP", "192.168.0.1"), ("X-Sent-By", "requests")]
37833843
)
37843844

37853845
r = await client.get("/", headers={"X-Sent-By": "aiohttp"})

tests/test_client_request.py

Lines changed: 1 addition & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from yarl import URL
1616

1717
import aiohttp
18-
from aiohttp import BaseConnector, hdrs, helpers, payload
18+
from aiohttp import BaseConnector, hdrs, payload
1919
from aiohttp.abc import AbstractStreamWriter
2020
from aiohttp.client_exceptions import ClientConnectionError
2121
from aiohttp.client_reqrep import (
@@ -1545,26 +1545,6 @@ def test_gen_default_accept_encoding(
15451545
assert _gen_default_accept_encoding() == expected
15461546

15471547

1548-
@pytest.mark.parametrize(
1549-
("netrc_contents", "expected_auth"),
1550-
[
1551-
(
1552-
"machine example.com login username password pass\n",
1553-
helpers.BasicAuth("username", "pass"),
1554-
)
1555-
],
1556-
indirect=("netrc_contents",),
1557-
)
1558-
@pytest.mark.usefixtures("netrc_contents")
1559-
def test_basicauth_from_netrc_present(
1560-
make_request: Any,
1561-
expected_auth: Optional[helpers.BasicAuth],
1562-
):
1563-
"""Test appropriate Authorization header is sent when netrc is not empty."""
1564-
req = make_request("get", "http://example.com", trust_env=True)
1565-
assert req.headers[hdrs.AUTHORIZATION] == expected_auth.encode()
1566-
1567-
15681548
@pytest.mark.parametrize(
15691549
"netrc_contents",
15701550
("machine example.com login username password pass\n",),

0 commit comments

Comments
 (0)