Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES/11634.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed blocking I/O in the event loop when using netrc authentication by moving netrc file lookup to an executor -- by :user:`bdraco`.
29 changes: 29 additions & 0 deletions aiohttp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,10 @@
EMPTY_BODY_METHODS,
BasicAuth,
TimeoutHandle,
basicauth_from_netrc,
frozen_dataclass_decorator,
get_env_proxy_for_url,
netrc_from_env,
sentinel,
strip_auth_from_url,
)
Expand Down Expand Up @@ -586,6 +588,20 @@ async def _request(
)
):
auth = self._default_auth

# Try netrc if auth is still None and trust_env is enabled.
# Only check if NETRC environment variable is set to avoid
# creating an expensive executor job unnecessarily.
if (
auth is None
and self._trust_env
and url.host is not None
and os.environ.get("NETRC")
):
auth = await self._loop.run_in_executor(
None, self._get_netrc_auth, url.host
)

# It would be confusing if we support explicit
# Authorization header with auth argument
if auth is not None and hdrs.AUTHORIZATION in headers:
Expand Down Expand Up @@ -1131,6 +1147,19 @@ def _prepare_headers(self, headers: LooseHeaders | None) -> "CIMultiDict[str]":
added_names.add(key)
return result

def _get_netrc_auth(self, host: str) -> BasicAuth | None:
"""
Get auth from netrc for the given host.

This method is designed to be called in an executor to avoid
blocking I/O in the event loop.
"""
netrc_obj = netrc_from_env()
try:
return basicauth_from_netrc(netrc_obj, host)
except LookupError:
return None

if sys.version_info >= (3, 11) and TYPE_CHECKING:

def get(
Expand Down
6 changes: 0 additions & 6 deletions aiohttp/client_reqrep.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,8 @@
BasicAuth,
HeadersMixin,
TimerNoop,
basicauth_from_netrc,
frozen_dataclass_decorator,
is_expected_content_type,
netrc_from_env,
parse_mimetype,
reify,
sentinel,
Expand Down Expand Up @@ -1068,10 +1066,6 @@ def update_auth(self, auth: BasicAuth | None, trust_env: bool = False) -> None:
"""Set basic auth."""
if auth is None:
auth = self.auth
if auth is None and trust_env and self.url.host is not None:
netrc_obj = netrc_from_env()
with contextlib.suppress(LookupError):
auth = basicauth_from_netrc(netrc_obj, self.url.host)
if auth is None:
return

Expand Down
2 changes: 1 addition & 1 deletion aiohttp/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,7 +364,7 @@ def __init__(self) -> None:
# https://www.rfc-editor.org/rfc/rfc9110#section-8.3-5
self.set_default_type("application/octet-stream")

def get_content_type(self) -> Any:
def get_content_type(self) -> str:
"""Re-implementation from Message

Returns application/octet-stream in place of plain/text when
Expand Down
14 changes: 0 additions & 14 deletions requirements/constraints.txt
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,8 @@ cfgv==3.4.0
# via pre-commit
charset-normalizer==3.4.4
# via requests
cherry-picker==2.6.0
# via -r requirements/dev.in
click==8.3.0
# via
# cherry-picker
# pip-tools
# slotscheck
# towncrier
Expand Down Expand Up @@ -86,8 +83,6 @@ frozenlist==1.8.0
# via
# -r requirements/runtime-deps.in
# aiosignal
gidgethub==5.4.0
# via cherry-picker
gunicorn==23.0.0
# via -r requirements/base.in
identify==2.6.15
Expand Down Expand Up @@ -174,7 +169,6 @@ pygments==2.19.2
# sphinx
pyjwt==2.8.0
# via
# gidgethub
# pyjwt
pyproject-hooks==1.2.0
# via
Expand Down Expand Up @@ -210,7 +204,6 @@ pyyaml==6.0.3
# via pre-commit
requests==2.32.5
# via
# cherry-picker
# sphinx
# sphinxcontrib-spelling
rich==14.2.0
Expand Down Expand Up @@ -244,14 +237,9 @@ sphinxcontrib-spelling==8.0.1 ; platform_system != "Windows"
# via -r requirements/doc-spelling.in
sphinxcontrib-towncrier==0.5.0a0
# via -r requirements/doc.in
stamina==25.1.0
# via cherry-picker
tenacity==9.1.2
# via stamina
tomli==2.3.0
# via
# build
# cherry-picker
# coverage
# mypy
# pip-tools
Expand Down Expand Up @@ -281,8 +269,6 @@ typing-extensions==4.15.0
# virtualenv
typing-inspection==0.4.2
# via pydantic
uritemplate==4.2.0
# via gidgethub
urllib3==2.5.0
# via requests
uvloop==0.21.0 ; platform_system != "Windows"
Expand Down
1 change: 0 additions & 1 deletion requirements/dev.in
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,4 @@
-r test.in
-r doc.in

cherry_picker
pip-tools
14 changes: 0 additions & 14 deletions requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,8 @@ cfgv==3.4.0
# via pre-commit
charset-normalizer==3.4.4
# via requests
cherry-picker==2.6.0
# via -r requirements/dev.in
click==8.3.0
# via
# cherry-picker
# pip-tools
# slotscheck
# towncrier
Expand Down Expand Up @@ -84,8 +81,6 @@ frozenlist==1.8.0
# via
# -r requirements/runtime-deps.in
# aiosignal
gidgethub==5.4.0
# via cherry-picker
gunicorn==23.0.0
# via -r requirements/base.in
identify==2.6.15
Expand Down Expand Up @@ -169,7 +164,6 @@ pygments==2.19.2
# sphinx
pyjwt==2.8.0
# via
# gidgethub
# pyjwt
pyproject-hooks==1.2.0
# via
Expand Down Expand Up @@ -205,7 +199,6 @@ pyyaml==6.0.3
# via pre-commit
requests==2.32.5
# via
# cherry-picker
# sphinx
rich==14.2.0
# via pytest-codspeed
Expand Down Expand Up @@ -235,14 +228,9 @@ sphinxcontrib-serializinghtml==2.0.0
# via sphinx
sphinxcontrib-towncrier==0.5.0a0
# via -r requirements/doc.in
stamina==25.1.0
# via cherry-picker
tenacity==9.1.2
# via stamina
tomli==2.3.0
# via
# build
# cherry-picker
# coverage
# mypy
# pip-tools
Expand Down Expand Up @@ -272,8 +260,6 @@ typing-extensions==4.15.0
# virtualenv
typing-inspection==0.4.2
# via pydantic
uritemplate==4.2.0
# via gidgethub
urllib3==2.5.0
# via requests
uvloop==0.21.0 ; platform_system != "Windows" and implementation_name == "cpython"
Expand Down
32 changes: 28 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,6 @@ def blockbuster(request: pytest.FixtureRequest) -> Iterator[None]:
with blockbuster_ctx(
"aiohttp", excluded_modules=["aiohttp.pytest_plugin", "aiohttp.test_utils"]
) as bb:
# TODO: Fix blocking call in ClientRequest's constructor.
# https://github.com/aio-libs/aiohttp/issues/10435
for func in ["io.TextIOWrapper.read", "os.stat"]:
bb.functions[func].can_block_in("aiohttp/client_reqrep.py", "update_auth")
for func in [
"os.getcwd",
"os.readlink",
Expand Down Expand Up @@ -292,6 +288,34 @@ def netrc_contents(
return netrc_file_path


@pytest.fixture
def netrc_default_contents(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Create a temporary netrc file with default test credentials and set NETRC env var."""
netrc_file = tmp_path / ".netrc"
netrc_file.write_text("default login netrc_user password netrc_pass\n")

monkeypatch.setenv("NETRC", str(netrc_file))

return netrc_file


@pytest.fixture
def no_netrc(monkeypatch: pytest.MonkeyPatch) -> None:
"""Ensure NETRC environment variable is not set."""
monkeypatch.delenv("NETRC", raising=False)


@pytest.fixture
def netrc_other_host(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
"""Create a temporary netrc file with credentials for a different host and set NETRC env var."""
netrc_file = tmp_path / ".netrc"
netrc_file.write_text("machine other.example.com login user password pass\n")

monkeypatch.setenv("NETRC", str(netrc_file))

return netrc_file


@pytest.fixture
def start_connection() -> Iterator[mock.Mock]:
with mock.patch(
Expand Down
Loading
Loading