Unexpected "Never has no attribute..." error for declared context manager enter result type
ncoghlan opened this issue · 1 comments
Bug Report
The recent httpx-ws
release made the result of aconnect_ws
generic on the returned session type: frankie567/httpx-ws@dc716cc
Even though the newly introduced generic type variable is a bound type variable (anchored by AsyncWebsocketSession
, the previous concrete return type), mypy
started complaining that ws
in the following code needed a type declaration:
from httpx_ws import aconnect_ws
async def send_and_receive(target_url, msg):
with aconnect_ws(target_url) as ws:
await ws.send_json(msg)
return await ws.receive_json(msg)
However, adding that declaration didn't fix the problem:
from httpx_ws import aconnect_ws
async def send_and_receive(target_url, msg):
ws: AsyncWebSocketSession
with aconnect_ws(target_url) as ws:
await ws.send_json(msg)
return await ws.receive_json(msg)
Instead, it started reporting '"Never" has no attribute "send_json"' and '"Never" has no attribute "receive_json"'
The only version I've found that made mypy happy was to explicitly type the context manager with its old non-generic annotation:
from typing import AsyncContextManager
from httpx_ws import aconnect_ws
async def send_and_receive(target_url, msg):
ws_cm: AsyncContextManager[AsyncWebSocketSession] = aconnect_ws(target_url)
with ws_cm as ws:
await ws.send_json(msg)
return await ws.receive_json(msg)
To Reproduce
The above reproduces the problem with http-ws
. I haven't tried reproducing it with a custom async context manager.
Expected Behavior
I actually would have expected the bound type variable on the generic to keep mypy from complaining in the first place.
Failing that, I definitely expected the explicit predeclaration of ws
to work, not have it instead be reinterpreted as Never
.
The latter seemed like the much stranger result, hence using it as the issue title.
Actual Behavior
The above code is a slightly simplified version of the actual failing test case. The following errors are from the version of the real test case with ws: AsyncWebSocketSession
explicitly declared:
tests/test_sdk_bypass.py:30: error: "Never" has no attribute "send_json" [attr-defined]
tests/test_sdk_bypass.py:31: error: "Never" has no attribute "receive_json" [attr-defined]
tests/test_sdk_bypass.py:55: error: "Never" has no attribute "send_json" [attr-defined]
tests/test_sdk_bypass.py:60: error: "Never" has no attribute "receive_json" [attr-defined]
Your Environment
- Mypy version used: 1.13.0
- Mypy command-line flags:
--strict
- Mypy configuration options from
mypy.ini
(and other config files): defaults - Python version used: Python 3.12
- httpx-ws version: 0.7.0
Thanks for the report!
For context, here's an abridged copy of aconnect_ws
's signature:
AsyncSession = typing.TypeVar("AsyncSession", bound="AsyncWebSocketSession")
@contextlib.asynccontextmanager
async def aconnect_ws(
# ...
*,
session_class: type[AsyncSession] = AsyncWebSocketSession, # type: ignore[assignment]
) -> typing.AsyncGenerator[AsyncSession, None]: ...
The root issue here is that mypy does not consider the default argument to session_class
(AsyncWebSocketSession
) when resolving the type variable AsyncSession
. This causes AsyncSession
to be inferred as Never
in absence of other type information. See #3737 for details and workarounds.
On httpx-ws's side, this could be fixed by adding a default type argument for the type variable, e.g.:
AsyncSession = typing.TypeVar("AsyncSession", bound="AsyncWebSocketSession", default="AsyncWebSocketSession")
or by adding overloads for the cases where session_class
is and is not provided:
@overload
async def aconnect_ws(
# ...
) -> typing.AsyncGenerator[AsyncWebSocketSession, None]: ...
@overload
async def aconnect_ws(
# ...
*,
session_class: type[AsyncSession],
) -> typing.AsyncGenerator[AsyncSession, None]: ...
To work around this as an end user, you can manually pass the default session class when calling aconnect_ws
:
async def send_and_receive(target_url, msg):
async with aconnect_ws(target_url, session_class=AsyncWebSocketSession) as ws:
await ws.send_json(msg)
return await ws.receive_json(msg)
or add an annotation like so:
async def send_and_receive(target_url, msg):
connection: AsyncContextManager[AsyncWebSocketSession] = aconnect_ws(target_url)
async with connection as ws:
await ws.send_json(msg)
return await ws.receive_json(msg)
Closing as duplicate of #3737.