Using type hints in Python 2 libraries

With PEP 484 Python allows you to annotate variables and functions with their types:

from typing import Iterable

def stringify_list(xs: Iterable[int]) -> Iterable[str]:
    return [str(x) for x in xs]

Why is this useful?

  • You can use mypy to lint your code against mismatching types.
  • PyCharm supports those type annotations.

For application developers this is a great story. The typing module is part of Python 3's standard library, and for Python 2 a PyPI package exists. They don't care about the extra dependency because their app has already too many. Or they port their app to Python 3 first.

What about libraries? At Sentry we vrecently added type hints to our SDK for Python. The motivation was to give IDE users nicer autocompletion and find a few bugs in our own code.

We defined two requirements. They apply to a lot of libraries that support Python 2:

  • No new install dependencies, no new runtime dependencies. Sure, pip is good now, but the majority of our users don't use type hints and won't see the value of having to install typing. We have users who forked our SDK to remove all dependencies because of their constrained legacy environment. This would become a harder job if every file imported types from typing.
  • As little runtime cost as possible. Importing typing is runtime cost, defining types is runtime cost. There's no reason for any of that to happen when using our library outside of development.

This blogpost is about the decisions we made within those requirements. We had two options: Stub files and type hint comments.

Stub files

Stub files are a way to annotate regular Python 2-compatible code in Python 3 syntax. For each .py file one would have a .pyi file that contains function and type definitions in Python 3 syntax, but with empty function bodies.

This satisfies our requirements because all imports from typing would only live in the .pyi files, which are not used at runtime.

Stub files seem to overwrite what mypy would've otherwise found out about the real code:

# File: test.py
def stringify_list(xs, random_new_parameter):
    return [str(x) for x in xs]

# File: test.pyi
from typing import Iterable

def stringify_list(xs: Iterable[int]) -> Iterable[str]:
    return [str(x) for x in xs]

Even though we added a new argument to stringify_list, mypy still accepts this code because it thinks the function takes one argument. For this reason we decided against using stub files because we feared that those could get out of sync with their companion .py files.

Type hint comments

We chose the only other option: Use type hint comments. Those work across Python 2 and 3 as well as stub files do, but can't get out of sync with the implementation:

from typing import Iterable

def stringify_list(xs, random_new_parameter):
    # type: (Iterable[int]) -> Iterable[str]
    return [str(x) for x in xs]

This time mypy rejects the code with error: Type signature has too few arguments.

Eliminating imports

With this approach we need to import typing. This adds runtime cost, and while that alone would be negligible, we also now have to install the typing module under Python 2.

Luckily there is a cheap way to get rid of these pesky imports:

if False:
    from typing import Iterable

def stringify_list(xs, random_new_parameter):
    # type: (Iterable[int]) -> Iterable[str]
    return [str(x) for x in xs]

This avoids importing the typing module at runtime while keeping mypy happy. We had this version in use for quite a while until we discovered that mypy had a more official way that didn't depend on undocumented quirks:

MYPY = False
if MYPY:
    from typing import Iterable

<rest of the code as above>

The mypy documentation mentions this hack as a solution to import cycles while type-checking, but it works just as well for our purposes.

Function overloading

All of our imports are now disabled at runtime. This works for type hint comments, but some other annotations are not comments. For example, function overloads:

from typing import Union, overload

@overload
def foo(x):
    # type: (int) -> None
    pass

@overload
def foo(x):
    # type: (str) -> None
    pass

def foo(x):
    # type: (Union[int, str]) -> None
    pass

The issue is the overload decorator. Wrapping only the first two function declarations in if MYPY confuses mypy so much it thinks the last declaration is an unnecessary redefinition. Other approaches we tried typecheck successfully but require more duplicated type signatures which could get out of sync unnoticed.

Our solution is:

MYPY = False

if MYPY:
    from typing import Union, overload
else:
    def overload(x):
        return x

<rest of the code as above>

This is not quite zero runtime overhead but close enough.

Conclusion

What we have right now gives us nicer code intelligence in IDEs without disrupting the rest of our users with added dependencies or runtime overhead. The majority of our SDK is still untyped or weakly typed, but we did find some bugs in the SDK using mypy.

Mypy is generally a good, useful piece of software. Unfortunately the story for annotating existing Python 2 code ignores the issues that come from additional dependencies. Documented workarounds like if MYPY are an afterthought even for their intended purpose. This will likely slow down adoption of type hints in libraries and make the typeshed (the repository of type annotations for third-party packages that don't have any) a permanent necessity.