Skip to content

asyncio, inspect: use static types in TypeIs#15931

Merged
srittau merged 3 commits into
python:mainfrom
JelleZijlstra:typeis-top
Jun 24, 2026
Merged

asyncio, inspect: use static types in TypeIs#15931
srittau merged 3 commits into
python:mainfrom
JelleZijlstra:typeis-top

Conversation

@JelleZijlstra

Copy link
Copy Markdown
Member

The set-theoretically correct way to write these TypeIs functions is
with a type that includes all instances of the type they are narrowing to;
for example "Awaitable[Any]" means "some unknown set of awaitables";
"Awaitable[object]" means "all awaitables, regardless of what they await to".
We can't spell this type for cases involving invariant type parameters or
callable parameter sets, so leave those alone for now.

This may cause new type checker diagnostics because it will lead type
checkers to often infer more precise types. Those errors are at least
in theory correct, but let's see how big the fallout is.

The set-theoretically correct way to write these TypeIs functions is
with a type that includes all instances of the type they are narrowing to;
for example "Awaitable[Any]" means "some unknown set of awaitables";
"Awaitable[object]" means "all awaitables, regardless of what they await to".
We can't spell this type for cases involving invariant type parameters or
callable parameter sets, so leave those alone for now.

This may cause new type checker diagnostics because it will lead type
checkers to often infer more precise types. Those errors are at least
in theory correct, but let's see how big the fallout is.
@github-actions

This comment has been minimized.

@JelleZijlstra

Copy link
Copy Markdown
Member Author

(Will post back with results from going over mypy-primer.)

@AlexWaygood

Copy link
Copy Markdown
Member

I previously tried a variant of this in #14784 and posted some primer analysis in that thread, if that helps

@JelleZijlstra

JelleZijlstra commented Jun 24, 2026

Copy link
Copy Markdown
Member Author

Conclusions:

  • There's at least one clear mypy bug being exposed: Missing TypeIs narrowing with covariant generic mypy#21641.
  • A lot of the fallout is from code working with types of the form T | Awaitable[T]. That sort of code is always a little unsound; the earlier permissive narrowing with Any smoothed that over.
  • The changes related to non-async narrowing generally seem like clearer positives. For example, the isclass change flags a few cases of monkeypatching that type checkers probably should be complaining about.
  • To reduce fallout I'm going to back out the async-related changes. Some of them do indicate real bugs, often mismatch between having Coroutine in a type and then checking isawaitable(), or vice versa. However, others feel like they're going to make life harder for mypy users.

Below is Codex's summary of the changes, with links to the relevant code:

Details

Analysis of mypy-primer fallout from #15931

This is an analysis of the mypy-primer output in
#15931 (comment).

The PR changes several TypeIs return types from gradual specializations such
as Awaitable[Any] or Coroutine[Any, Any, Any] to top materializations such as
Awaitable[object] or Coroutine[object, Never, object].

High-level summary

Most of the fallout is from code using the common helper pattern:

T | Awaitable[T]

and then narrowing with inspect.isawaitable() or asyncio.iscoroutine().
This pattern is intrinsically a little unsound if T itself can be awaitable.
The old Any-based TypeIs signatures hid that problem by preserving too much
type information through the runtime predicate.

However, several primer results appear to be mypy limitations rather than real
new user-code problems:

  • negative TypeIs narrowing does not subtract Coroutine[Any, Any, T] when
    the predicate target is Coroutine[object, Never, object];
  • mypy does not always simplify intersections with AsyncGeneratorType enough
    to recover the original yielded type;
  • dynamic test/code patterns using inspect.isclass() or monkeypatching can
    now need explicit casts.

Alex's earlier related analysis on #14784 is relevant:

Classification by project

psycopg

Project: https://github.com/psycopg/psycopg

Affected code:
https://github.com/psycopg/psycopg/blob/master/psycopg_pool/psycopg_pool/_acompat.py#L184-L185

Likely classification: mostly true positive / exposed unsoundness.

The code has a helper that accepts a callable returning T | Coroutine[Any, Any, T],
then awaits if isawaitable(rv) is true. This depends on the predicate preserving
the original T through the await. That is not generally sound if T itself can
be awaitable.

Typical workaround:

from collections.abc import Awaitable
from inspect import isawaitable
from typing import TypeVar, cast

T = TypeVar("T")

async def await_maybe(value: T | Awaitable[T]) -> T:
    if isawaitable(value):
        return await cast(Awaitable[T], value)
    return cast(T, value)

For a public API, overloads give better call-site inference.

aiohttp-devtools

Project: https://github.com/aio-libs/aiohttp-devtools

Affected code:
https://github.com/aio-libs/aiohttp-devtools/blob/master/aiohttp_devtools/runserver/config.py#L240

Likely classification: true positive or annotation too broad.

The annotation permits Awaitable[Application], but the code checks
asyncio.iscoroutine(app), not inspect.isawaitable(app). A non-coroutine
awaitable would not be awaited. Either the annotation should say Coroutine[...],
or the runtime check should use isawaitable().

aiohttp

Project: https://github.com/aio-libs/aiohttp

Affected code:
https://github.com/aio-libs/aiohttp/blob/master/aiohttp/web.py#L295

Likely classification: mostly harmless locally, but still points at a real
annotation/runtime mismatch.

The code checks asyncio.iscoroutine(app) and then immediately casts to
Application. Since the public parameter type includes Awaitable[Application],
a non-coroutine awaitable would not be awaited. The immediate cast means this
diagnostic is not a serious local typing problem, but the new type does expose
the mismatch.

Prefect

Project: https://github.com/PrefectHQ/prefect

Affected code:

Likely classification: mixed.

The flow_engine.py case is the same broad T | Awaitable[T] issue and is
probably a real static unsoundness or an invariant that the annotation does not
express.

The concurrency/v1/sync.py case looks like a mypy bug. The code does:

result = acquire_concurrency_slots(...)
assert not asyncio.iscoroutine(result)
limits = result

After the assertion, mypy should narrow away the coroutine arm. With the PR
stubs, it does not.

Minimal repro:

from collections.abc import Coroutine
from typing import Any
from typing_extensions import Never, TypeIs

def is_coro(x: object) -> TypeIs[Coroutine[object, Never, object]]:
    return False

def needs_list(x: list[int]) -> None: ...

def f(x: list[int] | Coroutine[Any, Any, list[int]]) -> None:
    assert not is_coro(x)
    reveal_type(x)  # list[int] | Coroutine[Any, Any, list[int]]
    needs_list(x)   # should be OK, but errors

With the old gradual predicate:

def is_coro(x: object) -> TypeIs[Coroutine[Any, Any, Any]]:
    return False

mypy narrows correctly to list[int].

This appears not to be primarily about the contravariant type parameter. A
contravariant-only generic works:

from typing import Any, Generic, TypeVar
from typing_extensions import Never, TypeIs

T_contra = TypeVar("T_contra", contravariant=True)

class Sink(Generic[T_contra]): ...

def is_sink(x: object) -> TypeIs[Sink[Never]]:
    return False

def f(x: int | Sink[Any]) -> None:
    assert not is_sink(x)
    reveal_type(x)  # int

But a covariant top-materialization case fails:

from typing import Any, Generic, TypeVar
from typing_extensions import TypeIs

T_co = TypeVar("T_co", covariant=True)

class Source(Generic[T_co]): ...

def is_source(x: object) -> TypeIs[Source[object]]:
    return False

def f(x: int | Source[Any]) -> None:
    assert not is_source(x)
    reveal_type(x)  # int | Source[Any]

So the relevant mypy bug seems to be negative TypeIs subtraction failing to
subtract a gradual specialization (Source[Any]) from its top materialization
(Source[object]).

For deployment.py, the code uses a TYPE_CHECKING-only assertion:

if TYPE_CHECKING:
    assert iscoroutine(coro)
infrastructure = await coro

This is a likely false positive / type-checker workaround becoming stale. The
assertion now narrows to a coroutine returning object, so the result of
await coro loses its useful type.

steam.py

Project: https://github.com/Gobot1234/steam.py

Affected code:
https://github.com/Gobot1234/steam.py/blob/master/steam/utils.py#L894

Likely classification: true positive / exposed unsoundness.

This is another T | Awaitable[T] helper. The old Any signature preserved
too much type information after the isawaitable() check.

strawberry

Project: https://github.com/strawberry-graphql/strawberry

Affected code:

Likely classification: mixed.

await_maybe.py is the same T | Awaitable[T] helper pattern and is likely a
true positive in the static sense.

object_type.py is more of an ergonomics false positive: inspect.isclass(cls)
narrows cls to type[object], losing the original decorator T, but the
runtime function is intentionally a class decorator preserving the same class
type. This probably needs a cast in user code.

hydra-zen

Project: https://github.com/mit-ll-responsible-ai/hydra-zen

Affected code:
https://github.com/mit-ll-responsible-ai/hydra-zen/blob/main/src/hydra_zen/third_party/beartype.py#L125

Likely classification: true positive, according to Alex's previous analysis.

inspect.isclass(obj) proves that obj is a class, but not that assigning to
obj.__init__ is statically safe. The old type[Any] narrowing hid the method
assignment issue.

scrapy

Project: https://github.com/scrapy/scrapy

Affected code:

Likely classification: mixed.

utils/defer.py is another generic awaitable helper case.

cmdline.py is likely a real type-safety issue: inspect.isclass(obj) proves
only that the entry point loaded a class, not that it loaded a ScrapyCommand
subclass. The code should use issubclass() or cast after whatever runtime
validation Scrapy wants.

discord.py

Project: https://github.com/Rapptz/discord.py

Affected code:
https://github.com/Rapptz/discord.py/blob/master/discord/utils.py#L714

Likely classification: true positive / exposed unsoundness.

This is another helper returning T after checking whether a value is awaitable.
The old Any-based isawaitable() return type preserved the type variable
through the await in a way that is not generally sound.

pandera

Project: https://github.com/pandera-dev/pandera

Affected code:

Likely classification: mixed.

The unused ignore is a true positive / cleanup.

The data_type.foo = 1 error is inside a pytest.raises() block, and Alex
previously pointed out that it is supposed to raise an exception:
#14784 (comment)

So that part is a false positive from mypy's perspective, or at least not a
real code problem.

pytest

Project: https://github.com/pytest-dev/pytest

Affected code:
https://github.com/pytest-dev/pytest/blob/main/testing/test_monkeypatch.py#L439

Likely classification: false positive.

The test intentionally monkeypatches functools.partial and then checks
inspect.isclass() before and after the monkeypatch context. Static analysis
does not model that runtime mutation, so the unreachable diagnostic is noise.

trio

Project: https://github.com/python-trio/trio

Affected code:
https://github.com/python-trio/trio/blob/master/src/trio/_core/_tests/test_ki.py#L682-L687

Likely classification: true positive in the type-theoretic sense, per Alex's
previous analysis.

inspect.isasyncgen() proves an object is an async generator, but it does not
prove the generator yields None, so passing it to a function annotated as
requiring AsyncGenerator[None, None] is not justified by the predicate alone.

Similarly, after inspect.isgenerator() or inspect.iscoroutine(), the static
type does not prove that sending None is allowed. This is inconvenient for the
test, but the stricter type is not wrong.

Alex's previous analysis:
#14784 (comment)

anyio

Project: https://github.com/agronholm/anyio

Affected code:
https://github.com/agronholm/anyio/blob/master/src/anyio/from_thread.py#L278

Likely classification: true positive / exposed unsoundness.

The callable can return Awaitable[T_Retval] | T_Retval; after
isawaitable(retval_or_awaitable), the awaited result is inferred as object
with the new stubs. This is the same generic awaitable helper issue.

kopf

Project: https://github.com/nolar/kopf

Affected code:
https://github.com/nolar/kopf/blob/main/kopf/_core/intents/piggybacking.py#L165

Likely classification: true positive / exposed missing annotation information.

The code calls a method whose result is str | None in one version and
awaitable in another version. inspect.isawaitable() cannot itself prove that
awaiting the value returns str | None; that fact has to come from the API type
or from a cast.

graphql-core

Project: https://github.com/graphql-python/graphql-core

Affected code:
https://github.com/graphql-python/graphql-core/blob/main/tests/execution/test_middleware.py#L265-L267

Likely classification: mypy limitation / false positive.

Alex's previous analysis applies here. The result of subscribe() has a richer
type involving AsyncIterator[ExecutionResult], and after assert inspect.isasyncgen(agen), mypy should ideally simplify the intersection enough
to know that await agen.__anext__() is an ExecutionResult, not object.

Previous analysis:
#14784 (comment)

paasta

Project: https://github.com/yelp/paasta

Affected code:
https://github.com/yelp/paasta/blob/master/paasta_tools/async_utils.py#L162

Likely classification: mixed / ergonomics issue.

The code closes the input only when it is a coroutine:

if inspect.iscoroutine(async_fn_or_awaitable):
    async_fn_or_awaitable.close()

At runtime this is legitimate: coroutine objects have close(). The new
Coroutine[object, Never, object] type should also have close(), so if mypy
reports "None" has no attribute "close" here, it may be interacting with the
specific union/control-flow shape in this file rather than indicating a real
runtime problem.

@github-actions

Copy link
Copy Markdown
Contributor

Diff from mypy_primer, showing the effect of this PR on open source code:

strawberry (https://github.com/strawberry-graphql/strawberry)
+ strawberry/types/object_type.py:295: error: Value of type variable "T" of "_inject_default_for_maybe_annotations" cannot be "object"  [type-var]
+ strawberry/types/object_type.py:296: error: Value of type variable "T" of "_wrap_dataclass" cannot be "object"  [type-var]

hydra-zen (https://github.com/mit-ll-responsible-ai/hydra-zen)
+ src/hydra_zen/third_party/beartype.py:125: error: Cannot assign to a method  [method-assign]

scrapy (https://github.com/scrapy/scrapy)
+ scrapy/cmdline.py:70: error: Incompatible types in assignment (expression has type "object", target has type "ScrapyCommand")  [assignment]

pandera (https://github.com/pandera-dev/pandera)
+ pandera/dtypes.py:568: error: Unused "type: ignore" comment  [unused-ignore]
+ tests/pandas/test_dtypes.py:306: error: Item "object" of "Any | object" has no attribute "foo"  [union-attr]

pytest (https://github.com/pytest-dev/pytest)
+ testing/test_monkeypatch.py:439: error: Statement is unreachable  [unreachable]

trio (https://github.com/python-trio/trio)
+ src/trio/_core/_tests/test_ki.py:682: error: Argument 1 to "_consume_async_generator" has incompatible type "AsyncGeneratorType[object, Never]"; expected "AsyncGenerator[None, None]"  [arg-type]
+ src/trio/_core/_tests/test_ki.py:687: error: Argument 1 to "send" of "GeneratorType" has incompatible type "None"; expected "Never"  [arg-type]

graphql-core (https://github.com/graphql-python/graphql-core)
+ tests/execution/test_middleware.py:265: error: "object" has no attribute "data"  [attr-defined]
+ tests/execution/test_middleware.py:267: error: "object" has no attribute "data"  [attr-defined]

@JelleZijlstra

Copy link
Copy Markdown
Member Author

Primer analysis:

  • strawberry: probably a false positive, not entirely sure why mypy isn't accepting this though.
  • hydra-zen: dynamic code, type checker error seems fair
  • scrapy: true positive, the code isn't safe
  • pandera: probably positive
  • pytest: weird monkeypatching code, type checker error seems fair
  • trio: errors are correct, it's doing unsafe things
  • graphql-core: haven't fully figured out what's going on, possibly a mypy bug

@srittau

srittau commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator
  • strawberry looks like a true positive to me. T is bound to type, but _inject_default_for_maybe_annotations expects a type[T], i.e. a type[type[Any]].

    def _inject_default_for_maybe_annotations(
        cls: builtins.type[T], annotations: dict[str, Any]
    ) -> None:

Didn't look in detail at the rest, but LGTM. I wouldn't mind to see the async changes in a separate PR, so we could have another look at the primer failures in isolation. On a first glance they looked acceptable to me.

@srittau srittau merged commit f7ef056 into python:main Jun 24, 2026
58 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants