106

How should a context manager be annotated with Python type hints?

import typing

@contextlib.contextmanager
def foo() -> ???:
    yield

The documentation on contextlib doesn't mention types much.

The documentation on typing.ContextManager is not all that helpful either.

There's also typing.Generator, which at least has an example. Does that mean I should use typing.Generator[None, None, None] and not typing.ContextManager?

import typing

@contextlib.contextmanager
def foo() -> typing.Generator[None, None, None]:
    yield
Peter
  • 3,322
  • 3
  • 27
  • 41
  • 2
    It's a generator, and it yields, sends, and returns `None`, so it's a `Generator[None, None, None]`. It doesn't matter if you use it for a context manager. – internet_user Apr 09 '18 at 13:10
  • If you have any idea on what this specific context manager will be used for, you can annotate for the expected types, else you'd be pretty much accepting anything (even None) – Onilol Apr 09 '18 at 13:52
  • In my specific case I just want to use the context manager for logging (timing) so the yield, send and return values really are `None`. – Peter Apr 09 '18 at 14:44
  • The documentation in contextlib doesn't mention any types because the decorator is not type hinted: https://github.com/python/cpython/blob/f192a558f538489ad1be30aa145e71d942798d1c/Lib/contextlib.py#L260. Many static code analysers assume the decorator doesn't change the type hint. – Philip Couling Mar 30 '23 at 18:48

7 Answers7

69

Whenever I'm not 100% sure what types a function accepts, I like to consult typeshed, which is the canonical repository of type hints for Python. Mypy directly bundles and uses typeshed to help it perform its typechecking, for example.

We can find the stubs for contextlib here: https://github.com/python/typeshed/blob/master/stdlib/contextlib.pyi

if sys.version_info >= (3, 2):
    class GeneratorContextManager(ContextManager[_T], Generic[_T]):
        def __call__(self, func: Callable[..., _T]) -> Callable[..., _T]: ...
    def contextmanager(func: Callable[..., Iterator[_T]]) -> Callable[..., GeneratorContextManager[_T]]: ...
else:
    def contextmanager(func: Callable[..., Iterator[_T]]) -> Callable[..., ContextManager[_T]]: ...

It's a little overwhelming, but the line we care about is this one:

def contextmanager(func: Callable[..., Iterator[_T]]) -> Callable[..., ContextManager[_T]]: ...

It states that the decorator takes in a Callable[..., Iterator[_T]] -- a function with arbitrary arguments returning some iterator. So in conclusion, it would be fine to do:

@contextlib.contextmanager
def foo() -> Iterator[None]:
    yield

So, why does using Generator[None, None, None] also work, as suggested by the comments?

It's because Generator is a subtype of Iterator -- we can again check this for ourselves by consulting typeshed. So, if our function returns a generator, it's still compatible with what contextmanager expects so mypy accepts it without an issue.

Wolkenarchitekt
  • 20,170
  • 29
  • 111
  • 174
Michael0x2a
  • 58,192
  • 30
  • 175
  • 224
  • 9
    Looking into a [potential dupe](https://stackoverflow.com/questions/56984315/vscode-intellisense-not-showing-for-python-in-with-as-statement), I came across this answer. It seems like the return type for a generator used in a context manager should reflect what the context manager returns, i.e. `ContextManager[_T]`. With that, the static checker in my IDE was able to successfully infer the type of the context variable, while it did not work with `Iterator`. Can you check? I'd like to flag the other question as a dupe, but as it stands, this answer does not solve the problem in the other Q. – shmee Jul 11 '19 at 09:58
  • @shmee I'm not sure I agree that "the return type for a generator used in a context manager should reflect what the context manager returns". The function returns what it returns, and I usually think of the decorator as modifying the function...so if you want to know what the decorated function returns you need to look at the type annotations for the decorator. – Dustin Wyatt Mar 18 '22 at 20:29
  • `Iterator` works but feels semantically wrong. – jamesdlin Jun 26 '22 at 01:57
  • @jamesdlin yeah, because it's not actually an iterator in all scenarios lol. It could literally be anything... It could be an object, a string, literally anything can be yielded – Ryan Glenn Jul 30 '22 at 22:46
  • The downside of this answer is that many tools do NOT use typeshed and instead assume that `@contextmanager` passes through the type hinting unadulterated. So if you hint to please Mypy, you will find tools like `mkdocstrings-python` generate incorrect signatures in your documentation. Unfortunately using type hinting from typeshed as canonical where that hinting is not contained in the python source code has created a bit of a mess in getting tools to agree. – Philip Couling Mar 30 '23 at 19:05
  • Thank you @PhilipCouling for your various comments. I found them very helpful. – Myridium Aug 26 '23 at 06:02
46

With my PyCharm, I do the following to make its type hinting work:

from contextlib import contextmanager
from typing import ContextManager

@contextmanager
def session() -> ContextManager[Session]:
    yield Session(...)

UPD: see comments below. Looks like this thing makes PyCharm happy, but not mypy

kolypto
  • 31,774
  • 17
  • 105
  • 99
  • 18
    This doesn't seem to work for me. Mypy says `error: The return type of a generator function should be "Generator" or one of its supertypes` and `error: Argument 1 to "contextmanager" has incompatible type "Callable[[Abc, Any, Any], ContextManager[Any]]"; expected "Callable[..., Iterator[]]"` – CMCDragonkai Feb 12 '20 at 04:55
  • 1
    I guess mypy is too strict :D I don't have a better annotation at the moment – kolypto Jul 24 '20 at 22:56
  • 2
    Type hinting now works for me thanks to this. PyCharm (2020.1.2 Community Edition) and python 3.8. – Robino Jul 30 '20 at 12:12
  • Thanks, this helped with PyCharm but not mypy. Perhaps a single solution does not yet exist to make both tools happy – eric.frederich May 25 '21 at 19:47
  • 11
    @kolypto No, it's not that mypy is too strict. PyCharm is simply wrong. You should be annotating it as Generator, and the decorator will take that Generator and return a ContextManager. – Neil G Nov 16 '21 at 01:16
  • @NeilG Incorrect. `@contextmanager` is itself [not type hinted](https://github.com/python/cpython/blob/f192a558f538489ad1be30aa145e71d942798d1c/Lib/contextlib.py#L260) and the way it wraps the method is [with a simple `@wraps` decorator](https://github.com/python/cpython/blob/f192a558f538489ad1be30aa145e71d942798d1c/Lib/contextlib.py#L287) so the type hint information is not doctored by the decorator. Therefore the type hint seen by callers will be identical to the one coded. Here the best thing to do is hint the function as callers will use it, *NOT* what mypi thinks is correct internally. – Philip Couling Mar 30 '23 at 18:46
  • 1
    @PhilipCouling You're mistaken: the type hints for the standard library are in the typeshed, right here: https://github.com/python/typeshed/blob/a06bf1fe1d0c95d431913c0276bf3615cb5b2561/stubs/decorator/decorator.pyi#L71 – Neil G Mar 30 '23 at 23:13
  • @NeilG test that's my point. They are in a seperate code base which tools are by no means explicitly required to read. Ergo it's incorrect to suggest Pycharm is "simply wrong". The story is a whole lot more complicated than that. – Philip Couling Mar 30 '23 at 23:20
  • @PhilipCouling I don't agree with your assessment. The typeshed are the annotations for the standard library and builtins. All of the major type checkers (mypy, pyright, pytype) defer to it. If Pycharm doesn't want to use the typeshed, that's fine, but that will lead to incorrect results. And anyway, by your logic, applying `contextlib.contextmanager` using a function call would give different results than applying it as a decorator. This is clearly undesirable. – Neil G Mar 30 '23 at 23:32
  • @NeilG undesirable yes, wrong no. And let's go with "many" major type checkers. Other major linters including pylint do not defer to typeshed. It's less clear to me how authoritative it is since it also contains third party libraries. It's helpful but I don't see it listed as authoritative. Ergo not using it is not "wrong". – Philip Couling Mar 31 '23 at 00:16
  • @PhilipCouling I thought it about today, and I see your point about how type checkers are free to do whatever they like. Right now for example, few type checkers actually use the annotations for the descriptor protocol (`__get__`). However, I do think that the annotation for `contextmanager` given by the typeshed is correct. And I think not using it and assuming that all decorators simply let their functions pass through is, as you admit, "undesirable". You're right to correct my wording though--"wrong" was too harsh. – Neil G Mar 31 '23 at 05:05
26

I didn't find a good answer here around annotating contextmanagers which yield values in a way which passes mypy checks under Python 3.10. According to the Python 3.10 documentation for contextlib.contextmanager

The function being decorated must return a generator-iterator when called

typing.Generators are annotated as Generator[YieldType, SendType, ReturnType]. So, in the case of a function which yields a pathlib.Path, we can annotate our functions like this:

from typing import Generator
from contextlib import contextmanager

@contextmanager
def working_directory() -> Generator[Path, None, None]:
    with TemporaryDirectory() as td:
        yield Path(td)

However, Generators which don't specify SendType or ReturnType can instead be annotated as typing.Iterator:

from typing import Iterator
from contextlib import contextmanager

@contextmanager
def working_directory() -> Iterator[Path]:
    with TemporaryDirectory() as td:
        yield Path(td)

Finally, since PEP 585 -- Type Hinting Generics In Standard Collections was adopted in Python 3.9, typing.Iterator and typing.Generator are deprecated in favour of the collections.abc implementations

from collections.abc import Iterator
from contextlib import contextmanager

@contextmanager
def working_directory() -> Iterator[Path]:
    with TemporaryDirectory() as td:
        yield Path(td)
  • 1
    **Thanks, this is an awesome answer.** I can confirm that `Generator[T, None, None]` is the correct type annotation. MyPy is very happy with that. (Oh and other answers about annotating as `ContextManager[T]` are 100% WRONG, so don't listen to them. Listen to James! :) PS: I didn't try the `collections.abc.Iterator[T]` that you mentioned, since it's dependent on Python 3.9+, but that looks even better. – Mitch McMabers Jun 17 '23 at 01:17
13

A. The return type of a function decorated by @contextmanager is Iterator[None].

from contextlib import contextmanager
from typing import Iterator

@contextmanager
def foo() -> Iterator[None]:
    yield

B. The type of the context manager itself is AbstractContextManager:

from contextlib import AbstractContextManager

def make_it_so(context: AbstractContextManager) -> None:
    with context:
        ...

You may also see typing.ContextManager used, but that has been deprecated in favor of contextlib.AbstractContextManager since Python 3.9.

David Foster
  • 6,931
  • 4
  • 41
  • 42
7

The Iterator[] version doesn't work when you want to return the contextmanager's reference. For instance, the following code:

from typing import Iterator

def assert_faster_than(seconds: float) -> Iterator[None]:
    return assert_timing(high=seconds)

@contextmanager
def assert_timing(low: float = 0, high: float = None) -> Iterator[None]:
    ...

Will produce an error on the return assert_timing(high=seconds) line:

Incompatible return value type (got "_GeneratorContextManager[None]", expected "Iterator[None]")

Any legit usage of the function:

with assert_faster_than(1):
    be_quick()

Will result in something like this:

"Iterator[None]" has no attribute "__enter__"; maybe "__iter__"?
"Iterator[None]" has no attribute "__exit__"; maybe "__next__"?
"Iterator[None]" has no attribute "__enter__"; maybe "__iter__"?
"Iterator[None]" has no attribute "__exit__"; maybe "__next__"?

You could fix it like this...

def assert_faster_than(...) -> Iterator[None]:
    with assert_timing(...):
        yield

But I am going to use the new ContextManager[] object instead and silence out mypy for the decorator:

from typing import ContextManager

def assert_faster_than(seconds: float) -> ContextManager[None]:
    return assert_timing(high=seconds)

@contextmanager  # type: ignore
def assert_timing(low: float = 0, high: float = None) -> ContextManager[None]:
    ...
Joe
  • 2,496
  • 1
  • 22
  • 30
  • 9
    You want the type signatures of `assert_faster_than` and `assert_timing` to look the same, but you're applying `@contextmanager` to only one of them. I think the right thing to do is to declare `assert_faster_than(...) -> ContextManager[None]`, but `assert_timing(..) -> Iterator[None]`. – Marius Gedminas May 15 '20 at 10:40
6

Based on PEP-585 the correct annotation type seems to be AbstractContextManager (see https://www.python.org/dev/peps/pep-0585/#implementation). Than you can use the following code:

import contextlib

@contextlib.contextmanager
def foo() -> contextlib.AbstractContextManager[None]:
    yield

This is the only solution that correctly works together with PyCharm (along with typing.ContextManager, but this one should be deprecated from Python 3.9). It correctly helps you when you use it in with statement (type hints) which is very helpful.

But when I go back to the original question ("How should a context manager be annotated with Python type hints?") it depends. From my point of view the correct one should be the one I mentioned. But this seems not working with mypy (yet). There were some updates regarding this PEP (see https://github.com/python/mypy/issues/7907), but since I'm not much experienced with mypy I might be missing something here.

Nerxis
  • 3,452
  • 2
  • 23
  • 39
  • This gives en error with Python 3.7.9 (when running the code): `TypeError: 'ABCMeta' object is not subscriptable` – levsa Sep 02 '21 at 08:42
  • 1
    @levsa: This PEP is meant for Python 3.9 and newer, if you want to try this for older Python versions (from 3.7) you have to use `from __future__ import annotations` to be forward-compatible. – Nerxis Sep 02 '21 at 09:45
  • 1
    PEP-585 mentions that `typing.ContextManager` is deprecated in favor of `contextlib.AbstractContextManager`. But it does not say it's the right annotation type for a function decorated with `@contextlib.contextmanager`. – Yacine Nouri Nov 28 '22 at 19:15
  • This PyCharm issue seems to be related: https://youtrack.jetbrains.com/issue/PY-36444/PyCharm-doesnt-infer-types-when-using-contextlib.contextmanager-decorator – Yacine Nouri Nov 28 '22 at 19:19
0

I had a similar problem when implementing the abstract method:

class Abstract(ABC):
    @abstractmethod
    def manager(self) -> ContextManager[None]:
        pass


class Concrete(Abstract):
    @contextmanager
    def manager(self) -> Iterator[None]:
        try:
            yield
        finally:
            pass

Annotating the abstract method with the ContextManager[None] and the implementation with Iterator[None] solves the problem.

funnydman
  • 9,083
  • 4
  • 40
  • 55