Search code examples
typesmypyabstract-data-type

How to let mypy know that an existing type supports certain properties and methods?


I am teaching myself Python and am trying to make my way through mypy's type checking system, but I am kind of lost among types, classes, abstract classes, generic types and the like.

So, I would like to make a generic/abstract type/class to represent dates, specifying that this type/class must have year, month and day properties and must support comparison operators < and <=, and possibly other methods. I know it really sounds like a job for an abstract class, but I am not proficient with OO and my previous attempts with an abstract class were unsuccessful at passing mypy's type check. Based on the generic/abstract pattern, I define a function that adds n months to a date. Next, I want to define a specific/concrete date type based on the date type from module datetime, and I redefine the add_months function to operate with that type, but the idea is of course to call the function written for the generic/abstract pattern, not to duplicate the code.

Hope the code below makes my intent clearer (my code is split in two files):

File dates_generic.py:

from typing import Callable, TypeVar


Year = int

Month = int

Day = int


class A:
    year: Year
    month: Month
    day: Day

    def __lt__(self: A, other: A) -> bool:
        ...

    def __le__(self: A, other: A) -> bool:
        ...


Date = TypeVar('Date', bound=A)


def add_months(x: Date,
               n: int,
               days_in_month: Callable[[Year, Month], int],
               date_make: Callable[[Year, Month, Day], Date])-> Date:
    r = (x.month + n - 1) % 12
    q = (x.month + n - 1) // 12

    y = x.year + q

    m = r + 1

    d = min(x.day, days_in_month(y, m))

    return date_make(y, m, d)

File dates_pylib.py:


import calendar as cal

import dates_generic as dg

import datetime as dt

from dates_generic import Year, Month, Day

from typing import NewType


DateP = NewType('DateP', dt.date)


def date_make(y: Year, m: Month, d: Day) -> DateP:
    return DateP(dt.date(y, m, d))

def days_in_month(y: Year,
                  m: Month):
    return cal.monthrange(y, m)[1]

def add_months(x: DateP,
               n: int) -> DateP:
    return dg.add_months(x, n, days_in_month, date_make)

My problem is that mypy still raises the following issue with my function dates_pylib.add_months:

Value of type variable "Date" of "add_months" cannot be "DateP"

And PyCharm, which I'm using as an IDE, adds its own:

Expected type '(int, int, int) -> Any' (matched generic type '(int, int, int) -> Date'), got '(y: int, m: int, d: int) -> DateP' instead

From the 1st message, I seem to understand that mypy is not aware that the type DateP implements the year, month and day properties and the comparison operators. This message disappears if I remove bound=A in dates_generic.py, but then mypy complains that the unbounded type Date does not have the year, month and day properties.

The 2nd message makes less sense to me because I've read from PEP-0484 that "Every type is consistent with Any.", so I would expect (int, int, int) -> Any to be substitutable with (int, int, int) -> DateP.

I may be looking for something similar to Haskell's typeclass constraints, which allow you to specify methods that a type must support, but I am not sure how to emulate this in Python.


Solution

  • It sounds like you want to describe the functionality your Date implementations must have using a Protocol (helpfully nicknamed "static duck typing"). Protocols are totally Yet Another Typing Thing, and it's definitely a lot to learn about all of this stuff at once, but they're really nice once you get used to them.

    Defining a Protocol tells the typechecker that "if you find anything that does all this stuff, it's an implementer of this Protocol and that means people who expect implementers of this Protocol should be happy with it". It's a lot like an Abstract Base Class, but also not - it's just an interface specification, and nobody actually needs to inherit from it (and inheriting from a Protocol is basically kinda a no-op anyhow - mypy will figure out whether any class implements a Protocol whether or not you declare that the class inherits from that Protocol). You can read the PEP about Protocols here.

    You may not need this, or may have already found it, but since it sounds like you're trying to describe and re-implement a subset of the functionality of datetime, the datetime type stubs may be of use to you as well.