Search code examples
pythonooptype-hintingmypytyping

Best way to avoid mypy error when None is a possible result


I have a Processor class that looks like this:

class Processor:
    def __init__(self, payment_table: List[PaymentTable]):
        self.payment_table = payment_table
    
    def get_payment_table(self, year: int, month: int) -> Optional[PaymentTable]:
        ptable = None
        for table in self.payment_tables:
            if table.is_applicable(year, month):
                ptable = table
        return ptable

    def process_payment(self, contract: Contract, year: int, month: int):
        ptable = self.get_payment_table(year, month)
        payment_value = ptable.hour_value * contract.hours
        # Here logic was simplified just to get to the point...

mypy complains (correctly about ptable.hour_value:

Item "None" of "Optional[PaymentTable]" has no attribute "hour_value"

When searching for payment tables with provided arguments (month and year) it's possible that we do not have a payment table that suffices this values. So, the result of get_payment_table could be None, then I should hint the return type as Optional[PaymentTable].

I know of strategies to deal with the problem of not finding a table (i.e. I can raise an error if we doesn't find any payment table). I'm interested here in how should I type hint it for not getting this mypy error.

In other words: you have a method that can return None and you want to use this to get a object for another function to use. How do you build it? How do you type it? What's the pythonic way of doing this? I accept answers that change my code entirely.


Solution

  • Since it's not possible to process a payment if there is no payment table, I'd suggest that the simplest (and hence "pythonic") way of doing this would be to have get_payment_table raise an exception if there is no payment table, and allow that exception to raise from process_payment:

        def get_payment_table(self, year: int, month: int) -> PaymentTable:
            """Get payment table, raise KeyError if none."""
            for table in self.payment_tables:
                if table.is_applicable(year, month):
                    return table
            raise KeyError(f"No payment table for {year}/{month}")
    
        def process_payment(self, contract: Contract, year: int, month: int) -> None:
            """Process payment.  Raises KeyError if no payment table."""
            ptable = self.get_payment_table(year, month)
            payment_value = ptable.hour_value * contract.hours
    

    I used KeyError here but you might prefer to define your own PaymentTableError class instead.

    Since get_payment_table now returns PaymentTable (or raises), mypy won't complain about using ptable.hour_value, since it's not possible to reach that line of code with ptable being anything other than a PaymentTable.

    If you want process_payment to fail silently instead of raising, you'd do:

        def process_payment(self, contract: Contract, year: int, month: int) -> None:
            """Process payment.  No-op if no payment table."""
            try:
                ptable = self.get_payment_table(year, month)
            except KeyError:
                return
            payment_value = ptable.hour_value * contract.hours
    

    In this case we don't raise, but mypy is still happy, and the code is pretty explicit about the fact that we're returning early due to a lower level error.