Support suppressing generator context managers

Hey y’all,
type checkers complain about this:

from contextlib import suppress

def foo(x: int, y: int) -> float:
    with suppress(ZeroDivisionError):
        return x / y

and that’s great, that’s exactly what is expected :slight_smile:

Any calls to foo() with y=0 return None and the type checker will be happy as soon as we correct the return type of foo tofloat | None.

Type checkers know that because suppress.__exit__'s return type is bool.
Context managers that annotate their __exit__ as returning bool | None are assumed to be always non-suppressing–that’s a heuristic that works for most context managers and it’s fine.

Problem

Currently, all generator context managers (so context managers created from generators using the @contextmanager decorator) and generator context manager decorators (decorators from @contextmanager-based context managers) are assumed to be non-suppressing (__exit__() -> bool | None).

Therefore, all the cases below pass type checking, because of incorrect unreachability inference:

from collections.abc import Generator
from contextlib import contextmanager, suppress

@contextmanager
def suppressing1() -> Generator[None]:
    with suppress(ZeroDivisionError):
        yield

def foo() -> float:
    with suppressing1():
        return 1 / 0

@suppressing1()
def bar() -> float:
    return 1 / 0

@contextmanager
def suppressing2() -> Generator[None]:
    try:
        yield
    except ZeroDivisionError:
        pass

def foo2() -> float:
    with suppressing2():
        return 1 / 0

@suppressing2()
def bar2() -> float:
    return 1 / 0

@contextmanager
def suppressing3() -> Generator[None]:
    with suppressing2():
        yield

def foo3() -> float:
    with suppressing3():
        return 1 / 0

@suppressing3()
def bar3() -> float:
    return 1 / 0

assert (
    foo() is None
    and bar() is None
    and foo2() is None
    and bar2() is None
    and foo3() is None
    and bar3() is None
)

The passing assertion at the bottom makes it visible that all float-annotated functions in fact return None.

A fix is needed for generator context managers and generator context manager decorators.

Proposal

  1. Change typeshed to allow specifying __exit__ return types using type variables
  2. Implement a PoC to analyze context managers around yields in type checkers to infer the types bound to those type variables
    • Work in progress, any tips from mypy / pyright / ty / other type checkers folks are very welcome!
  3. Describe that in the typing specification

Technical overview (very brief and general)

The work in https://github.com/python/typeshed/pull/14439 reveals errors in 2 packages, which I described in the PR comments. Overall, this patch seems to play out well with all packages checked by primer and is therefore review-ready.

What was changed in typeshed

Instead of relying on a type variable for carrying the function in context manager decorator, we’re using a ParamSpec and joining the return type with _ExcReturnT that should always be Never or None. We can specify the __exit__ return type of a generator context manager as well.

Inference rules

Type checkers need to treat bodies of generator context managers specially. I believe some sort of check has to be added that such a generator context manager has one yield in every path (which is a separate issue, as type checkers allow more than 2 yields in one branch as well! See also Special semantics for generator context managers).

Then, if a context manager has a “suppressing context” around the yield (either a try-except or another context manager with bool exit) it needs to infer that the _ExcReturnT is None and _ExitT_co is bool or, if the context around yield is not suppressing, _ExcReturnT is Never and _ExitT_co is bool | None (as default).

Questions

  • Should this be a PEP?
  • Do you think it’s a useful change?
  • Do you have any concerns regarding that change?
1 Like

I’m working on a reference implementation for mypy.

The return type of __exit__ has been discussed a lot. Have you tried finding previous discussions on topic and try and see why the current state is the way it is?

_GeneratorContextManager.__exit__ return type doesn’t seem to have had been discussed, I only see it was added in the patch stdlib: Add many missing dunder overrides by AlexWaygood · Pull Request #7231 · python/typeshed · GitHub by @AlexWaygood.

However, if you mean the specific meanings of __exit__ return type from the typing specification — this proposal doesn’t touch on that, so it’s out of scope of the discussion.

I don’t think this is a change that makes sense to make. The problem is real and should be fixed, but this isn’t a good fit for the type system to try and infer absent an effects system given the numerous ways this can be supressed vs the behavior of the contextmanager decorators.

This could be addressed with adding the following two optional kwargs to the contextmanager decorator:

may_suppress: bool = False, always_suppresses: bool = False

and having overloads on the return type here.

Thanks for chiming in @mikeshardmind!

I see where you’re coming from, and I agree that this entire thing doesn’t sound like type checker area—I just can’t see a different approach that works for people.

Would you like to illustrate this with some practical examples?

Is there a solution that avoids complicating type checkers and still detects these false positives in pre-existing codebases?

In `[Async]ContextDecorator.__call__` return type should depend on the underlying context manager's `_ExitT_co` · Issue #13512 · python/typeshed · GitHub, I’ve considered something very similar (just with only one keyword parameter—we only need a distinction between bool | Nonenever suppresses and boolalways suppresses or suppresses just sometimes on the __exit__ return type).

This has a fundamental flaw: type checkers won’t report this problem in existing codebases automatically unless somebody takes time to migrate.

  • At some point, somebody will end up having to check every generator context manager in a codebase with millions of lines of code.
  • People make mistakes.

To help with that, a lint rule will be written that examines yields in the generator body to produce proper keyword arguments to @contextmanager, correctly informing about the suppressing behavior. Code changes all the time, so something will have to check for inconsistencies automatically. As a result, we’ll end up doubling the work we initially avoided when it could have just been solved by type checkers in the first place :slight_smile:

Most people write generator context managers to create reusable try-except pipelines, lots of them.
Special cases aren’t special enough to break the rules, although practicality beats purity… :stuck_out_tongue:

What do you think?

The contextmanager decorator (and async one too), use whether next raises (as well as if what it raises is StopIteration or not) to determine the return value.

As far as the type system is concerned, all functions are capable of raising (and even with an effects system, it would be hard to say otherwise in python, as things like SystemExit and KeyboardInterrupt exist) Then there is the additional problem that we actually aren’t interested in if the function can raise at all, but if the function can raise at a specific point that corresponds to when the decorator will transform the generator into being __exit__, which would be after the the first yield. Technically, you also want to be able to detect if the generator can yield multiple times as well, as this causes __exit__ itself to error.

This definitely isn’t capturable in python’s gradual type system currently, as we currently consider functions to be opaque as objects, only giving information about inputs and outputs, not what the function does, can do, or the order in which those things may happen internally.

A lint rule seems like a more viable direction. The closest thing to this that I know exists already is one of the flake8 async lint rules for catching other (async)contextmanager issues. (see: https://flake8-async.readthedocs.io/en/latest/rules.html, specifically async101, async119, and their implementations for similar prior art)

I don’t think I’ve ever intentionally suppressed an error with the contextmanager decorator. I’ve quickly grepped my personal code, and can only find use of it with as slightly less verbose writing of resource closing context managers than writing a class.

# setup something
try:
    yield something
finally:
    # cleanup without a return supressing any possible exception here

The one place I found that I do catch, I log then reraise