Search code examples
pythontype-hintingmypy

Issues with mypy, compose method and duplicated code


I've tried looking for answers to this, as I can't possibly be the first one to stumble across this issue, but my google-fu is failing me terribly.

Is it possible to make mypy understand that certain functions/methods are only meant to be called from within another method?

Let's take the following code as example (E: for clarity - this is a simplified, runnable version of a real-life problem that involves a Django model instance that has an intentionally nullable field):

from typing import Optional


class Foo:
    def __init__(self, value: Optional[int] = None) -> None:
        self.value = value


def compose_method(foo: Foo) -> None:
    if not foo.value:
        raise RuntimeError("Oh no!")

    __method_1(foo)
    __method_2(foo)


def __method_1(foo: Foo) -> None:
    foo.value + 1


def __method_2(foo: Foo) -> None:
    foo.value + 2

Running mypy on this file unsurprisingly results in:

test_mypy.py:18: error: Unsupported operand types for + ("None" and "int")
test_mypy.py:18: note: Left operand is of type "Optional[int]"
test_mypy.py:22: error: Unsupported operand types for + ("None" and "int")
test_mypy.py:22: note: Left operand is of type "Optional[int]"

It's obviously right, but it doesn't take into account the fact that the methods are only intended to be called from within compose_method - which already has checked that value != None. I don't want to duplicate code and put the if not foo.value: condition in each method where foo.value is used just to satisfy the tooling. At the same time, putting # type: ignore also doesn't quite sit right with me.

Is there some best practice/way to handle this that the community has agreed is good and right? Or am I just doomed to duplicate the code (and sure, the check can be put in a separate method and called in each of the methods that compose_method consists of) or use # type: ignore comments?


Solution

  • You may write things such that __method_1 and __method_2 are only called from within compose_method but, for all mypy knows, someone will import your file and calls those methods directly. Remember that Python doesn't really have a concept of private items.

    What you can do is squash the error by telling mypy, in essence, "I promise that value isn't None here," by using typing.cast.

    def __method_1(foo: Foo) -> None:
        foo.value = typing.cast(int, foo.value) + 1
    

    There is a slight performance hit here. Since this doesn't use +=, the Python compiler doesn't emit the INPLACE_ADD byte code. I originally thought to do instead typing.cast(int, foo.value) += 1. However, with the function call, it's no longer an L-value and therefore becomes invalid syntax.

    Because of that, the best thing to do may just be the # type: ignore.