Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unexpected "Never has no attribute..." error for declared context manager enter result type #18271

Closed
ncoghlan opened this issue Dec 9, 2024 · 1 comment
Labels
bug mypy got something wrong

Comments

@ncoghlan
Copy link

ncoghlan commented Dec 9, 2024

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
@ncoghlan ncoghlan added the bug mypy got something wrong label Dec 9, 2024
@brianschubert
Copy link
Collaborator

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.

@brianschubert brianschubert closed this as not planned Won't fix, can't repro, duplicate, stale Dec 9, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug mypy got something wrong
Projects
None yet
Development

No branches or pull requests

2 participants