Working with datetime and timezones

Working with datetime and timezones in python can generate lot of confusions and errors.

It is therefore a best practice to always specify the timezone you are working with (e.g. UTC, CEST…) explicitly as you can be sure the bread will always fall on the marmalade side.

For instance, the system time in your CICD pipeline runners might be different from your local system time, causing your unittests to fail on your runner but to succeed in local.

As a rule of thumb, it is always best to compare Coordinated Universal Time (UTC) with UTC.

Let’s see how to do this in practice with a code we will also unittest. Hence, we will catch up all the necessary lingo and discover the necessary tools of the trade.

Usage

Let’s say you have created a custom type Message:

from dataclasses import dataclass

@dataclass
class Message:
    content: str
    created_at: str

You want to convert the Message data class into a JSON-like dictionary – e.g. for better human readability:

from pprint import pprint
from dateutil.parser import parse
from datetime import datetime, timezone
from typing import Dict


def convert_message_to_dict(
    message: Message
) -> Dict[str, str]:
    """
    Method returning a dictionary populated with the
    Message's attributes.
    """
    return {
        "content": message.content,
        "created_at": parse(message.created_at)
        .astimezone(timezone.utc).isoformat(),
        "converted_at": datetime.now(timezone.utc)
        .isoformat(),
    }


if __name__ == "__main__":

    message = Message(
        content="foo",
        created_at="2024-02-14T01:32:00Z"
    )

    converted_message = convert_message_to_dict(message)

    pprint(converted_message)

Notes:

  1. I want my dictionary to only contain str so I need to convert datetimes to isoformat – which is the ISO_8601 standard.

  2. I explicitly want to define the timezone as UTC, using timezone.utc, to avoid any confusion.

  3. In isoformat you can explicitly define the timezone information at the end of the “YYYY-MM-DDTHH:mm:ss” string. To declare an UTC, either “Z” or the offset “+00:00”, “+0000” or “+00” can be used as postfix interchangeably.

  4. “Z” stands for “Zulu”, the military designation for UTC. It is the same as the UTC time. Read more.

  5. As for the “+” or “-” prefixing the offset, you add “+” moving East from the London meridian and subtract “-” moving West. Read more.

  6. pprint stands for pretty print and displays the results in a more human readable manner.

Here we go, running the script at “2024-02-13T13:52:09.265050+00:0” gives me the following output:

{
    "content": "foo",
    "created_at": "2024-02-14T01:32:00+00:00",
    "converted_at": "2024-02-13T13:52:09.265050+00:00"
}

Let’s unittest the above code.

Unittest datetime

In order to unittest the aforementioned code, we gonna use freezegun.

freeze_time is a nice python module that allow you to mock datetime.

import pytest
from freezegun import freeze_time
from datetime import datetime, timezone


@pytest.fixture()
def message() -> Message:
    """
    Fixture returning a minimal Message
    with 'created_at' expressed in UTC.
    """
    return Message(
        content="foo",
        created_at="2023-09-01T07:00:00Z"
    )

@freeze_time("2024-02-24T06:00:00", tz_offset=0)
def test_datetime():
    dt = datetime.now().isoformat()
    assert dt == "2024-02-24T06:00:00"


@freeze_time("2024-02-24T06:00:00", tz_offset=0)
def test_feed_to_dict(message):

    outcome = message_to_dict(message)

    expected_outcome = {
        "content": "foo",
        "created_at": "2023-09-01T07:00:00+00:00",
        "converted_at": "2024-02-24T06:00:00"
    }

    assert outcome == expected_outcome

Notes:

  • the trick is to compare UTC dates to UTC dates;

  • thanks to the fixture, the above unittest code respects the Arrange-Act-Assert pattern.

Leave a Reply

Your email address will not be published. Required fields are marked *