# Handling dates and times in Python

Python's `datetime` module is very much the go-to when dealing with dates and times, but it isn't without some quirks
and gotchas. Here are a few that one ought to be aware of:

- Python has two types of `datetime` objects: ones with timezone information attached to them ("timezone-aware") and 
ones without ("naive").
- Some functions and methods in the `datetime` module are sensitive to the local timezone of the machine on which 
the interpreter is running. For example, `datetime.now()` returns the current local time as a naive `datetime` object.
- Some functions and methods that operate on `datetime` objects (e.g., `datetime.timestamp()`) treat aware ones and naive
ones differently. They assume that naive ones are in the machine local timezone.

## A very simple example of things going wrong

This is enough ambiguity that some basic things can go wrong if one isn't careful. Consider the following seemingly 
straightforward exercise where we attempt to obtain the current UTC time as a `datetime`, then convert it to a POSIX timestamp, 
and then back again to a `datetime`:

```
Python 3.7.3 (default, May 15 2019, 10:26:11)
[Clang 10.0.1 (clang-1001.0.46.4)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> from datetime import datetime
>>> current_time_utc = datetime.utcnow()
>>> current_time_utc
datetime.datetime(2019, 12, 16, 4, 3, 47, 174417)
>>> current_posix_timestamp = current_time_utc.timestamp()
>>> current_posix_timestamp
1576487027.174417
>>> datetime.utcfromtimestamp(current_posix_timestamp)
datetime.datetime(2019, 12, 16, 9, 3, 47, 174417)
>>>
```

We don't get what we started with! This code was run in the `America/New_York` timezone, which is 5 hours behind UTC 
(at the moment), and this difference appears to have been added on to the end result.

The problem is that `current_time_utc` is a naive `datetime` and doesn't "know" that it's supposed to represent
a UTC time. Python assumes that it represents 4:03 AM in New York, and does the timestamp conversion accordingly,
adding on five extra hours.

## Recommendations

#### Use a library that provides a timezone database

It's important not to specify timezone offsets by hand, and to avoid mixing sources of timezone data. One should rely 
instead on a single library like `pytz` or `python-dateutil`. Here is how to obtain the UTC and New York timezone objects 
using `python-dateutil`:
```
>>> from dateutil import tz
>>> tz.UTC
tzutc()
>>> tz.gettz('America/New_York')
tzfile('/usr/share/zoneinfo/America/New_York')
```

#### Use timezone-aware UTC datetimes for internally generated timestamps

For keeping track of "what happened when" in an application it's best to stick to timezone-aware UTC `datetime`s. 
The recommended way to obtain these is:
```
>>> datetime.now(tz.UTC)
datetime.datetime(2019, 12, 16, 4, 39, 57, 322659, tzinfo=tzutc())
```

Note the `tzinfo` attribute on this `datetime` that distinguishes it as a timezone-aware one.

#### Convert to a local timezone when needed

Use the `datetime.astimezone` method to convert an aware UTC datetime to a local one:
```
>>> current_time_utc = datetime.now(tz.UTC)
>>> current_time_utc
datetime.datetime(2019, 12, 16, 4, 48, 16, 966623, tzinfo=tzutc())
>>> current_time_ny = current_time_utc.astimezone(tz.gettz('America/New_York'))
>>> current_time_ny
datetime.datetime(2019, 12, 15, 23, 48, 16, 966623, tzinfo=tzfile('/usr/share/zoneinfo/America/New_York'))
```

#### Serializing for inter-service communication

Serialize as a UTC timestamp in ISO format, or any other format where the zero offset is specified explicitly.

```
>>> current_time_utc.isoformat()
'2019-12-16T04:48:16.966623+00:00'
```

#### Dates

Dates aren't viewed as points in time, and Python doesn't have built-in facilities for attaching timezone information
to them. When storing user-entered dates, timezone data must be stored separately, if it is also specified.

When we need to treat a date as representing an interval of time
in a local timezone, and test if a given `datetime` falls within this interval, we can do so by converting the 
`datetime` to the local timezone, then using the `.date()` method
to obtain a `date` from it:

```
>>> ny_tz = tz.gettz('America/New_York')
>>> january_1 = date(2020, 1, 1)
>>> not_quite_there = datetime(2020, 1, 1, 4, 59, tzinfo=tz.UTC)
>>> not_quite_there.astimezone(ny_tz).date() == january_1
False
>>> there = datetime(2020, 1, 1, 5, 0, tzinfo=tz.UTC)
>>> there.astimezone(ny_tz).date() == january_1
True
```


 