Dates and Times and Types in Python
Dates And Times And Types

  Python’s standard
datetime
module is very powerful.  However, it has a couple of annoying flaws.
Firstly, datetimes are considered a kind of
date1, which causes problems.
Although datetime is a literal subclass of date so Mypy and isinstance
believe a datetime “is” a date, you cannot substitute a datetime for a
date in a program without provoking errors at runtime.
To put it more precisely, here are two programs which define a function with
type annotations, that mypy finds no issues with.  The first of which even
takes care to type-check its arguments at run-time.  But both raise
TypeErrors at runtime:
Comparing datetime to date:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14from datetime import date, datetime

def is_after(before: date, after: date) -> bool | None:
    if not isinstance(before, date):
        raise TypeError(f"{before} isn't a date")
    if not isinstance(after, date):
        raise TypeError(f"{after} isn't a date")
    if before == after:
        return None
    if before > after:
        return False
    return True

is_after(date.today(), datetime.now())


Traceback (most recent call last):
  File ".../date_datetime_compare.py", line 14, in <module>
    is_after(date.today(), datetime.now())
  File ".../date_datetime_compare.py", line 10, in is_after
    if before > after:
TypeError: can't compare datetime.datetime to datetime.date


Comparing “naive” and “aware” datetime:
from datetime import datetime, timezone, timedelta

def compare(a: datetime, b: datetime) -> timedelta:
    return a - b

compare(datetime.now(), datetime.now(timezone.utc))


Traceback (most recent call last):
  File ".../naive_aware_compare.py", line 6, in <module>
    compare(datetime.now(), datetime.now(timezone.utc))
  File ".../naive_aware_compare.py", line 4, in compare
    return a - b
TypeError: can't subtract offset-naive and offset-aware datetimes


In some sense, the whole point of using Mypy - or, indeed, of runtime
isinstance checks - is to avoid TypeError getting raised.  You specify
all the types, the type-checker yells at you, you fix it, and then you can know
your code is not going to blow up in unexpected ways.
Of course, it’s still possible to avoid these TypeErrors with runtime
checks, but it’s tedious and annoying to need to put a check for .tzinfo is
not None or not isinstance(..., datetime) before every use of - or >.
The problem here is that datetime is trying to represent too many things with
too few types.  datetime should not be inheriting from date, because it
isn’t a date, which is why > raises an exception when you compare the two.
Naive datetimes represent an abstract representation of a hypothetical civil
time which are not necessarily
tethered to specific moments in physical time.  You can’t know exactly what
time “today at 2:30 AM” is, unless you know where on earth you are and what the
rules are for daylight savings time in that place.  However, you can still
talk about “2:30 AM” without reference to a time zone, and you can even say
that “3:30 AM” is “60 minutes after” that time, even if, given potential
changes to wall clock time, that may not be strictly true in one specific
place during a DST transition.  Indeed, one of those times may refer to
multiple points in civil time at a particular location, when attached to
different sides of a DST
boundary.
By contrast, Aware datetimes represent actual moments in time, as they
combine civil time with a timezone that has a defined UTC offset to interpret
them in.
These are very similar types of objects, but they are not in fact the same,
given that all of their operators have slightly different (albeit closely
related) semantics.
Using datetype
I created a small library, datetype,
which is (almost) entirely type-time behavior.  At runtime, despite
appearances, there are no instances of new types, not even wrappers.
Concretely, everything is a date, time, or datetime from the standard
library.  However, when type-checking with Mypy, you will now get errors
reported from the above scenarios if you use the types from datetype.
Consider this example, quite similar to our first problematic example:
Comparing AwareDateTime or NaiveDateTime to date:
from datetype import Date, NaiveDateTime

def is_after(before: Date, after: Date) -> bool | None:
    if before == after:
        return None
    if before > after:
        return False
    return True

is_after(Date.today(), NaiveDateTime.now())


Now, instead of type-checking cleanly, it produces this error, letting you know
that this call to is_after will give you a TypeError.
date_datetime_datetype.py:10: error: Argument 2 to "is_after" has incompatible type "NaiveDateTime"; expected "Date"
Found 1 error in 1 file (checked 1 source file)


Similarly, attempting to compare naive and aware objects results in errors now.
We can even use the included AnyDateTime type variable to include a bound
similar to
AnyStr from
the standard library to make functions that can take either aware or naive
datetimes, as long as you don’t mix them up:
Comparing AwareDateTime to NaiveDateTime:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19from datetime import datetime, timezone, timedelta
from datetype import AwareDateTime, NaiveDateTime, AnyDateTime


def compare_same(a: AnyDateTime, b: AnyDateTime) -> timedelta:
    return a - b


def compare_either(
    a: AwareDateTime | NaiveDateTime,
    b: AwareDateTime | NaiveDateTime,
) -> timedelta:
    return a - b


compare_same(NaiveDateTime.now(), AwareDateTime.now(timezone.utc))

compare_same(AwareDateTime.now(timezone.utc), AwareDateTime.now(timezone.utc))
compare_same(NaiveDateTime.now(), NaiveDateTime.now())


naive_aware_datetype.py:13: error: No overload variant of "__sub__" of "_GenericDateTime" matches argument type "NaiveDateTime"
...
naive_aware_datetype.py:13: error: No overload variant of "__sub__" of "_GenericDateTime" matches argument type "AwareDateTime"
...
naive_aware_datetype.py:16: error: Value of type variable "AnyDateTime" of "compare_same" cannot be "_GenericDateTime[Optional[tzinfo]]"
Found 3 errors in 1 file (checked 1 source file)


Telling the Difference
Although the types in datetype are Protocols,
there’s a bit of included magic so that you can use them as type guards with
isinstance like regular types.  For example:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16from datetype import NaiveDateTime, AwareDateTime
from datetime import datetime, timezone

nnow = NaiveDateTime.now()
anow = AwareDateTime.now(timezone.utc)


def check(d: AwareDateTime | NaiveDateTime) -> None:
    if isinstance(d, NaiveDateTime):
        print("Naive!", d - nnow)
    elif isinstance(d, AwareDateTime):
        print("Aware!", d - anow)


check(NaiveDateTime.now())
check(AwareDateTime.now(timezone.utc))


Try it out, carefully
This library is very much alpha-quality; in the process of writing this blog
post, I made a half a dozen backwards-incompatible changes, and there are still
probably a few more left as I get feedback.  But if this is a problem you’ve
had within your own codebases - ensuring that dates and datetimes don’t get
mixed up, or requiring that all datetimes crossing some API boundary are
definitely aware and not naive, give it a try with pip install datetype and
let me know if it catches any bugs!

