Mypy is not enough
mypy is not enough - towards strict type checking in legacy projectsFor some Python developers, mypy is a gadget. For others, it is an indispensable tool in their workshop. We at Emplocity use mypy extensively to improve quality of our codebase. However, one can always do better. And with that said, let's do better with mypy.
Semi-strict mode
In a perfect world, you just run mypy --strict
in your project and if it
turns out green, congratulations, you're done. And yet most of the code seen
in the wild wouldn't pass strict type checking. There can be several reasons:
- untyped external libraries (usually the most likely reason)
- legacy code written without static typing in mind
- non-strict mypy pass considered good enough
Not to disencourage anyone - passing type checking without --strict
is
way better than no type checking and you should definitely go for it! But
we can find a middle ground between good enough and unobtainable strict.
This is what we call a semi-strict type checking.
What is --strict
anyway?
From mypy -h
:
Strict mode; enables the following flags: --warn-unused-configs, --disallow-any-generics, --disallow-subclassing-any, --disallow-untyped-calls, --disallow-untyped-defs, --disallow-incomplete-defs, --check-untyped-defs, --disallow-untyped-decorators, --no-implicit-optional, --warn-redundant-casts, --warn-unused-ignores, --warn-return-any, --no-implicit-reexport, --strict-equality
Each of these flags is a separate
configuration option.
This means you can include these one-by-one in your mypy config. Any subset
of these flags will take you further towards strict
than the default mypy
configuration. And once you reach the point where you can enable all of
these flags, you can basically replace all of them with strict
.
Examples
All of the following code snippets have some mistakes or shortcomings that the default mypy invocation will not tell you about. Sometimes these are harmless, and yet some of these issues can conceal serious errors.
Incomplete type definition
1def get_by_user_and_account(self, account: Account, user: User):
2
1mypy --disallow-incomplete-defs .
2function is missing a return type annotation
3
but also:
1def _filter(self, query: Query, **args) -> Query:
2
1mypy --disallow-incomplete-defs .
2function is missing a type annotation for one or more arguments
3
The fixes should be as simple as annotating the return type in the first case
and annotating kwargs
in the second one. (Note that **kwargs: T
means
that kwargs
is a dict[str, T]
).
Untyped definitions
This may or may not be obvious, but mypy doesn't typecheck code in functions or methods that are not annotated. Enabling this flag raises your attention to untyped code.
1@pytest.fixture
2def fake_service(dependency):
3 return service_factory(ServiceClass, dependency=dependency)
4
1mypy --disallow-untyped-defs . 2Function is missing a type annotation 3
Note: until disallow-untyped-defs
passes, use check-untyped-defs
to force
mypy to visit code which doesn't have type annotations.
Untyped calls
1class User:
2 def enable(self):
3 pass
4
5def enable_user(self, email: str) -> dict[str, str]:
6 user = User()
7 user.enable()
8 return {"message": "User enabled"}
9
This is the other side of that coin. You took care of annotating some function,
but you're calling an untyped method. Getting your code to pass type checking
with --disallow-untyped-calls
may be quite hard when you're using untyped
dependencies.
Any
generics
1def process(input: dict) -> ProcessingResult:
2
1mypy --disallow-any-generics . 2Missing type parameters for generic type "dict" 3
What is dict
? In typing context, dict
is not a type. It is a type
constructor. It needs a type parameter (or parameters) to actually become
a concrete type. In this case, we need to provide two type parameters for
the typing to make sense. For example, input: dict[str, str | int | bool]
.
Implicit optional
1def create_by_user(text: str, user: User = None) -> None:
2
1mypy --no-implicit-optional . 2Incompatible default for argument "user" (default has type "None", argument has type "User") 3
A User
instance cannot be None
, period. If None
is a legitimate value
for that argument, use Optional[User]
or User | None
(for Python 3.10+).
Note: This may become the default in future mypy releases, see: https://github.com/python/mypy/issues/9091
Implicit reexport
Given a following models/__init__.py
file:
1from .account import Account 2from .user import User 3
the following code should fail to typecheck with --no-implicit-reexport
.
1from models import Account, User 2
1mypy --no-implicit-reexport . 2Module "models" does not explicitly export attribute "Account"; implicit reexport disabled 3
Either import directly from the modules that define these classes (
from models.account import Account
) or define an explicit __all__
variable
in __init__.py
.
Strict equality
This has bitten us when migrating entities from language code (such as en
)
to locale (eg. en_US
).
1class Account:
2 _language: str = Column("language", String) # deprecated
3 locale: str = Column(String)
4
5 @property
6 def language(self) -> str:
7 return get_language_from_locale(self.locale)
8
9def filter_by_language(language: Language) -> list[Account]:
10 # ...
11 query = query.filter(Account.language == language)
12
1mypy --strict-equality . 2Non-overlapping equality check (left operand type: "Callable[[Account], str]", right operand type: "str") 3
Can you see the mistake? The filter_by_language
function uses SQLAlchemy
syntax for building SQL queries. You can use class attributes in place of
column names and the ORM translates between Python and SQL. However, we forgot
to change the filter to use locale
, which is a smart descriptor, and used
language
instead. That is a plain Python property and the ==
comparison
would crash at runtime. Unfortunately this was in an untested code branch,
but static analysis with mypy caught the mistake in the end.
Unused ignores
1category = kwargs.pop("category", None) # type: ignore 2
1mypy --warn-unused-ignores . 2Unused "type: ignore" comment 3
This particular line was refactored from a problematic, more complex expression.
The type: ignore
is now obsolete. Moreover, we aim to explain every ignore
with a comment telling why we decided to ignore this issue. After the refactor
that comment went away, but unnecessary annotation remained.
Summary
After several iterations we arrived at a semi-strict type checking mode.
Below is an excerpt from one of our projects' pyproject.toml
file that shows
which mypy flags we're using beyond the defaults.
1[tool.mypy]
2plugins = "sqlalchemy.ext.mypy.plugin"
3python_version = "3.9"
4mypy_path = "app/"
5check_untyped_defs = true
6disallow_any_generics = true
7disallow_incomplete_defs = true
8no_implicit_optional = true
9no_implicit_reexport = true
10strict_equality = true
11warn_redundant_casts = true
12warn_unused_ignores = true
13
14[[tool.mypy.overrides]]
15module = [
16 # sadly, here's a sizeable list of dependencies
17]
18ignore_missing_imports = true
19
Of course as more and more dependencies embrace static typing, we'll be refining the ignore list. And hopefully at some point, we'll be ready to switch to full strict mode. Regardless, we encourage everyone to extend the default configuration with at least a few of these flags, it's worth it. Happy typing!