Quantcast
Channel: Deciphering Glyph
Viewing all articles
Browse latest Browse all 52

Dates And Times And Types

$
0
0

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 datetimefor 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 91011121314
fromdatetimeimportdate,datetimedefis_after(before:date,after:date)->bool|None:ifnotisinstance(before,date):raiseTypeError(f"{before} isn't a date")ifnotisinstance(after,date):raiseTypeError(f"{after} isn't a date")ifbefore==after:returnNoneifbefore>after:returnFalsereturnTrueis_after(date.today(),datetime.now())
123456
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_afterifbefore>after:TypeError: can't compare datetime.datetime to datetime.date

Comparing “naive” and “aware” datetime:

123456
fromdatetimeimportdatetime,timezone,timedeltadefcompare(a:datetime,b:datetime)->timedelta:returna-bcompare(datetime.now(),datetime.now(timezone.utc))
123456
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 comparereturna-bTypeError: 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 avoidTypeError 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:

 1 2 3 4 5 6 7 8 910
fromdatetypeimportDate,NaiveDateTimedefis_after(before:Date,after:Date)->bool|None:ifbefore==after:returnNoneifbefore>after:returnFalsereturnTrueis_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.

12
date_datetime_datetype.py:10:error:Argument2to"is_after"hasincompatibletype"NaiveDateTime";expected"Date"Found1errorin1file(checked1sourcefile)

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 910111213141516171819
fromdatetimeimportdatetime,timezone,timedeltafromdatetypeimportAwareDateTime,NaiveDateTime,AnyDateTimedefcompare_same(a:AnyDateTime,b:AnyDateTime)->timedelta:returna-bdefcompare_either(a:AwareDateTime|NaiveDateTime,b:AwareDateTime|NaiveDateTime,)->timedelta:returna-bcompare_same(NaiveDateTime.now(),AwareDateTime.now(timezone.utc))compare_same(AwareDateTime.now(timezone.utc),AwareDateTime.now(timezone.utc))compare_same(NaiveDateTime.now(),NaiveDateTime.now())
123456
naive_aware_datetype.py:13:error:Nooverloadvariantof"__sub__"of"_GenericDateTime"matchesargumenttype"NaiveDateTime"...naive_aware_datetype.py:13:error:Nooverloadvariantof"__sub__"of"_GenericDateTime"matchesargumenttype"AwareDateTime"...naive_aware_datetype.py:16:error:Valueoftypevariable"AnyDateTime"of"compare_same"cannotbe"_GenericDateTime[Optional[tzinfo]]"Found3errorsin1file(checked1sourcefile)

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 910111213141516
fromdatetypeimportNaiveDateTime,AwareDateTimefromdatetimeimportdatetime,timezonennow=NaiveDateTime.now()anow=AwareDateTime.now(timezone.utc)defcheck(d:AwareDateTime|NaiveDateTime)->None:ifisinstance(d,NaiveDateTime):print("Naive!",d-nnow)elifisinstance(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!


  1. But, in typical fashion, not a kind of time... 


Viewing all articles
Browse latest Browse all 52

Latest Images

Trending Articles





Latest Images