Tech
Jerry Pussinen
Sep 30, 2021
Professional-grade mypy configuration
Type hints are an essential part of modern Python. Type hints are the enabler for clever IDEs, they document the code, and even reduce unit testing needs. Most importantly, type hints make the code more robust and maintainable which are the attributes that every serious project should aim for.
At Wolt we have witnessed the benefits of type hints, for example, in a web backend project which has 100k+ LOC and has already had 100+ Woltians contributing to it. In such a massive project the advantages of the type hints are evident. For example, the onboarding experience of the newcomers is smoother thanks to easier navigation of the codebase. Additionally, the static type checker surprisingly often catches bugs which would slip through the tests.
Mypy is the de facto static type checker for Python and it’s also the weapon of choice at Wolt. Alternatives include pyright from Microsoft, pytype from Google, and Pyre from Facebook. Mypy is great! However, its default configuration is too “loose” for serious projects. In this blog post, we’ll list a set of mypy configuration flags which should be flipped from their default value in order to gently enforce professional-grade type safety.
disallow_untyped_defs = True
By default, mypy doesn’t require any type hints, i.e. it has disallow_untyped_defs = False
by default. If you want to enforce the usage of the type hints (for all function/method arguments and return values), disallow_untyped_defs
should be flipped to True
. disallow_untyped_defs = True
is arguably the most important configuration option if you truly want to adopt typing in a Python project, whether it’s an existing or a fresh one.
You might think that it’d be too laborious to enforce type hints for all modules of an older (read: legacy) project. That’s of course true, and often not worth the initial investment. However, wouldn’t it be awesome if the mypy configuration would enforce all the new modules to have type hints? This can be achieved by
Setting
disallow_untyped_defs = True
on the global level (see global vs per-module configuration),Setting
disallow_untyped_defs = False
for the “legacy” modules.
In other words, strict by default and loose when needed. This allows gradual typing while still enforcing usage of type hints for new code. At Wolt, we have successfully employed this strategy, for example, in the aforementioned 100k+ LOC project. It’s good practice to get rid of the disallow_untyped_defs = False
option for an individual (legacy) module if the module requires some changes, e.g. a bug fix or a new feature. Also, adding types to legacy modules can be great fun during Friday afternoons. This way the type safety also gradually increases in the older parts of the codebase.
disallow_any_unimported = True
In short, disallow_any_unimported = True
is to protect developers from falsely trusting that their dependencies are in tip-top shape typing-wise. When something is imported from a dependency, it’s resolved to Any
if mypy can’t resolve the import. This can happen, for example, if the dependency is not PEP 561 (Distributing and Packaging Type Information) compliant.
Way too often developers tend to take a shortcut and use ignore_missing_imports = True
(even in the global level of the mypy configuration 😱) when they face something like
1
error: Skipping analyzing 'my-dependency': found module but no type hints or library stubs [import]
or
1 2 3
error: Library stubs not installed for "my-other-dependecy" (or incompatible with Python 3.9) [import] note: Hint: "python3 -m pip install types-my-other-dependency" note: (or run "mypy --install-types" to install all missing stub packages)
In the latter case the solution would be even immediately available and nicely suggested in the mypy output: just add a development dependency which contains the type stubs for the actual dependency. In fact, a number of popular projects have the stubs available. However, the former case is trickier. The developer could either
try to find an existing stub project from the wild wild web,
generate the stubs themselves (and hopefully consider open-sourcing them 😉) or
use
ignore_missing_imports = True
for the dependency in question.
disallow_any_unimported = True
is basically to protect the developers from the consequences of the ignore_missing_imports = True
case. For example, consider a project which depends on requests
and would ignore the imports in the mypy.ini file.
1 2
[mypy-requests] ignore_missing_imports = True
that would result in the following
1 2 3 4
from requests import Request def my_function(request: Request) -> None: reveal_type(request) # Revealed type is "Any"
and mypy would not give any errors. However, when disallow_any_unimported = True
is used, mypy would give
1
Argument 1 to "my_function" becomes "Any" due to an unfollowed import [no-any-unimported]
Getting the error would be great as it would force the developer to find a better solution for the missing import. The “lazy” solution would be to add a suitable type ignore:
1 2 3 4
from requests import Request def my_function(request: Request) -> None: # type: ignore[no-any-unimported] ...
This is already better as it nicely documents the Any
for the readers of the code. Less surprises, less bugs.
no_implicit_optional = True
If the snippet below is ran with the default configuration of mypy, the type of arg
is implicitly interpreted as Optional[str]
(i.e. Union[str, None]
) although it’s annotated as str
.
1 2
def foo(arg: str = None) -> None: reveal_type(arg) # Revealed type is "Union[builtins.str, None]"
In order to follow the Zen of Python’s (python -m this
) “explicit is better than implicit“, consider setting no_implicit_optional = True
. Then the above snippet would result in
1
error: Incompatible default for argument "arg" (default has type "None", argument has type "str") [assignment]
which would gently suggest to us to explicitly annotate the arg
with Optional[str]
:
1 2 3 4
from typing import Optional def foo(arg: Optional[str] = None) -> None: ...
This version is notably better, especially considering the future readers of the code.
check_untyped_defs = True
With the mypy defaults, this is legit:
1 2
def bar(arg): not_very_wise = "1" + 1
By default mypy doesn’t check what’s going on in the body of a function/method in case there are no type hints in the signature (i.e. arguments or return value). This won’t be an issue if disallow_untyped_defs = True
is used. However, more than often it’s not the case, at least for some modules of the project. Thus, setting check_untyped_defs = True
is encouraged. In the above scenario it would result in
1
error: Unsupported operand types for + ("str" and "int") [operator]
warn_return_any = True
This doesn’t give errors with the default mypy configuration:
1 2 3 4 5 6 7 8 9
from typing import Any def baz() -> str: return something_that_returns_any() def something_that_returns_any() -> Any: ...
You might think that the example is not realistic. If so, consider that something_that_returns_any
is a function in one of your project’s third-party dependencies or simply an untyped function.
With warn_return_any = True
, running mypy would result in:
1
error: Returning Any from function declared to return "str" [no-any-return]
which is the error that developers would probably expect to see in the first place.
show_error_codes = True and warn_unused_ignores = True
Firstly, adding a type ignore should be the last resort. Type ignore is a sign of developer laziness in the majority of cases. However, in some cases, it’s justified to use them.
It’s good practice to ignore only the specific type of an error instead of silencing mypy completely for a line of code. In other words, prefer # type: ignore[<error-code>]
over # type: ignore
. This both makes sure that mypy only ignores what the developer thinks it’s ignoring and also improves the documentation of the ignore. Setting the configuration option show_error_codes = True
will show the error codes in the output of the mypy runs.
When the code evolves, it’s not uncommon that a type ignore becomes useless, i.e. mypy would also be happy without the ignore. Well-maintained packages tend to include type hints sooner or later, for example, Flask added them in 2.0.0). It’s good practice to cleanup stale ignores. Using the warn_unused_ignores = True
configuration option ensures that there are only ignores which are effective.
Parting words
The kind of global mypy configuration that best suits depends on the project. As a rule of thumb, make the configuration strict by default and loosen it up per-module if needed. This ensures that at least all the new modules follow best practices.
If you are unsure how mypy interprets certain types, reveal_type
and reveal_locals
are handy. Just remember to remove those after running mypy as they’ll crash your code during runtime.
As a final tip: never ever “silence” mypy by using ignore_errors = True
. If you need to silence it for one reason or another, prefer type: ignore[<error-code>]
s or a set of targeted configuration flags instead of silencing everything for the module in question. Otherwise the modules which depend on the erroneous module are also affected and, in the worst scenario, the developers don’t have a clue about it as the time bomb is well-hidden deep inside a configuration file.
Type-safe coding!