Mypy is not enough

mypy is not enough - towards strict type checking in legacy projects
Zbigniew Siciarz
2022-01-17

For 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!

Ta strona korzysta z ciasteczek opartych na sztucznej inteligencji, aby zapewnić najlepsze doświadczenia zapoznaj sie z Ciasteczkową polityką