PEP 0615 Support for the IANA Time Zone Database in the Standard Library

../../_images/victor_stinner.png

https://fediverse.org/VictorStinner/status/1261577187787972608?s=20

../../_images/paul_ganssle.png

https://fediverse.org/pganssle/status/1262739961666777091?s=20

Pull request cpython/commit/62972d9d73e83d6e

This is the initial implementation of PEP 615, the zoneinfo module, ported from the standalone reference implementation (see https://www.python.org/dev/peps/pep-0615/#reference-implementation for a link, which has a more detailed commit history).

This includes (hopefully) all functional elements described in the PEP, but documentation is found in a separate PR. This includes:

  1. A pure python implementation of the ZoneInfo class

  2. A C accelerated implementation of the ZoneInfo class

  3. Tests with 100% branch coverage for the Python code (though C code coverage is less than 100%).

  4. A compile-time configuration option on Linux (though not on Windows)

Documentation

Accessing Time Zones

You can get a UTC time stamp from the datetime library like this:

>>> from datetime import datetime, timezone

>>> datetime.now(tz=timezone.utc)
datetime.datetime(2020, 9, 8, 15, 4, 15, 361413, tzinfo=datetime.timezone.utc)

Note that the resulting time stamp is time zone aware . It has an attached time zone as specified by tzinfo. Time stamps without any time zone information are called naive .

Paul Ganssle has been the maintainer of dateutil for years. He joined the Python core developers in 2019 and helped add a new zoneinfo standard library that makes working with time zones much more convenient.

zoneinfo provides access to the Internet Assigned Numbers Authority (IANA) Time Zone Database. The IANA updates its database several times each year, and it’s the most authoritative source for time zone information.

Using zoneinfo, you can get an object describing any time zone in the database:

>>> from zoneinfo import ZoneInfo
>>> ZoneInfo("America/Vancouver")
zoneinfo.ZoneInfo(key='America/Vancouver')

tzdata

You access a time zone using one of several keys. In this case, you use “America/Vancouver”.

Note: zoneinfo uses an IANA time zone database residing on your local computer. It’s possible—on Windows in particular —that you don’t have any such database or that zoneinfo won’t be able to locate it. If you get an error like the following, then zoneinfo hasn’t been able to locate a time zone database:

>>> from zoneinfo import ZoneInfo
>>> ZoneInfo("America/Vancouver")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
ZoneInfoNotFoundError: 'No time zone found with key America/Vancouver'

A Python implementation of the IANA Time Zone Database is available on PyPI as tzdata. You can install it with pip:

$ python -m pip install tzdata

Once tzdata is installed, zoneinfo should be able to read information about all supported time zones.

tzdata is maintained by the Python core team .

Note that you need to keep the package updated in order to have access to the latest changes in the IANA Time Zone Database.

You can make time zone–aware time stamps using the tz or tzinfo arguments to datetime functions

>>> from datetime import datetime
>>> from zoneinfo import ZoneInfo
>>> datetime.now(tz=ZoneInfo("Europe/Oslo"))
datetime.datetime(2020, 9, 8, 17, 12, 0, 939001,
                  tzinfo=zoneinfo.ZoneInfo(key='Europe/Oslo'))

>>> datetime(2020, 10, 5, 3, 9, tzinfo=ZoneInfo("America/Vancouver"))
datetime.datetime(2020, 10, 5, 3, 9,
                  tzinfo=zoneinfo.ZoneInfo(key='America/Vancouver'))

Having the time zone recorded with the time stamp is great for record keeping. It also makes it convenient to convert between time zones

>>> from datetime import datetime
>>> from zoneinfo import ZoneInfo
>>> release = datetime(2020, 10, 5, 3, 9, tzinfo=ZoneInfo("America/Vancouver"))
>>> release.astimezone(ZoneInfo("Europe/Oslo"))
datetime.datetime(2020, 10, 5, 12, 9,
                  tzinfo=zoneinfo.ZoneInfo(key='Europe/Oslo'))

Note that the time in Oslo is nine hours later than in Vancouver.

Investigating Time Zones

The IANA Time Zone Database is quite massive.

You can list all available time zones using zoneinfo.available_timezones()

>>> import zoneinfo
>>> zoneinfo.available_timezones()
{'America/St_Lucia', 'SystemV/MST7', 'Asia/Aqtau', 'EST', ... 'Asia/Beirut'}

>>> len(zoneinfo.available_timezones())
609

The number of time zones in the database may vary with your installation.

In this example, you can see that there are 609 time zone names listed.

Each of these time zones documents historical changes that have happened, and you can look more closely at each of them.

Kiritimati, also known as Christmas Island, is currently in the westernmost time zone in the world, UTC+14. That hasn’t always been the case. Before 1995, the island was on the other side of the International Date Line, in UTC-10. In order to move across the date line, Kiritimati completely skipped December 31, 1994.

You can see how this happened by looking closer at the “Pacific/Kiritimati” time zone object

>>> from datetime import datetime, timedelta
>>> from zoneinfo import ZoneInfo
>>> hour = timedelta(hours=1)
>>> tz_kiritimati = ZoneInfo("Pacific/Kiritimati")
>>> ts = datetime(1994, 12, 31, 9, 0, tzinfo=ZoneInfo("UTC"))

>>> ts.astimezone(tz_kiritimati)
datetime.datetime(1994, 12, 30, 23, 0,
                  tzinfo=zoneinfo.ZoneInfo(key='Pacific/Kiritimati'))

>>> (ts + 1 * hour).astimezone(tz_kiritimati)
datetime.datetime(1995, 1, 1, 0, 0,
                  tzinfo=zoneinfo.ZoneInfo(key='Pacific/Kiritimati'))

The new year started one hour after the clock read 23:00 on December 30, 1994, on Kiritimati. December 31, 1994, never happened !

You can also see that the offset from UTC changed:

>>> tz_kiritimati.utcoffset(datetime(1994, 12, 30)) / hour
-10.0

>>> tz_kiritimati.utcoffset(datetime(1995, 1, 1)) / hour
14.0

.utcoffset() returns a timedelta. The most effective way to calculate how many hours are represented by a given timedelta is to divide it by a timedelta representing one hour.

There are many other weird stories about time zones.

Paul Ganssle covers some of them in his PyCon 2019 presentation, Working With Time Zones: Everything You Wish You Didn’t Need to Know

See if you can find traces of any of the others in the Time Zone Database.

Using Best Practices

Working with time zones can be tricky.

However, with the availability of zoneinfo in the standard library, it’s gotten a bit easier. Here are a few suggestions to keep in mind when working with dates and times:

  • Civil times like the time of a meeting, a train departure, or a concert, are best stored in their native time zone. You can often do this by storing a naive time stamp together with the IANA key of the time zone.

    One example of a civil time stored as a string would be “2020-10-05T14:00:00,Europe/Oslo”. Having information about the time zone ensures that you can always recover the information, even if the time zones themselves change.

  • Time stamps represent specific moments in time and typically record an order of events. Computer logs are an example of this.

    You don’t want your logs to be jumbled up just because your time zone changes from Daylight Saving Time to standard time. Usually, you would store these kinds of time stamps as naive datetimes in UTC.

Because the IANA time zone database is updated all the time, you should be conscious of keeping your local time zone database in sync.

This is particularly important if you’re running any applications that are sensitive to time zones.

On Mac and Linux, you can usually trust your system to keep the local database updated. If you rely on the tzdata package, then you should remember to update it from time to time. In particular, you shouldn’t leave it pinned to one particular version for years.

Names like “America/Vancouver” give you unambiguous access to a given time zone.

However, when communicating time zone–aware datetimes to your users, it’s better to use regular time zone names. These are available as .tzname() on a time zone object

>>> from datetime import datetime
>>> from zoneinfo import ZoneInfo
>>> tz = ZoneInfo("America/Vancouver")
>>> release = datetime(2020, 10, 5, 3, 9, tzinfo=tz)
>>> f"Release date: {release:%b %d, %Y at %H:%M} {tz.tzname(release)}"
'Release date: Oct 05, 2020 at 03:09 PDT'

You need to provide a time stamp to .tzname(). This is necessary because the name of the time zone may change over time, such as for Daylight Saving Time:

>>> tz.tzname(datetime(2021, 1, 28))
'PST'

During the winter, Vancouver is in Pacific Standard Time (PST), while in summer it’s in Pacific Daylight Time (PDT).

zoneinfo is available in the standard library only for Python 3.9 and later. However, if you’re using earlier versions of Python, then you can still take advantage of zoneinfo. A backport is available on PyPI and can be installed with pip:

$ python -m pip install backports.zoneinfo

You can then use the following idiom when importing zoneinfo

try:
    import zoneinfo
except ImportError:
    from backports import zoneinfo

This makes your program compatible with all Python versions from 3.6 and up. See PEP 615 for more details about zoneinfo.

Examples

Example 1

1 from datetime import datetime
2 # https://docs.python.org/3.9/library/zoneinfo.html
3 from zoneinfo import ZoneInfo
4
5 now = datetime.now(tz=ZoneInfo("Europe/Paris"))
6 version = f"{now.year}-{now.month:02}-{now.day:02} {now.hour:02}H ({now.tzinfo})"

Example 2

  1# Configuration file for the Sphinx documentation builder.
  2#
  3# This file only contains a selection of the most common options. For a full
  4# list see the documentation:
  5# http://www.sphinx-doc.org/en/master/config
  6# -- Path setup --------------------------------------------------------------
  7# If extensions (or modules to document with autodoc) are in another directory,
  8# add these directories to sys.path here. If the directory is relative to the
  9# documentation root, use os.path.abspath to make it absolute, like shown here.
 10#
 11# import os
 12# import sys
 13# sys.path.insert(0, os.path.abspath('.'))
 14import platform
 15from datetime import datetime
 16from zoneinfo import ZoneInfo
 17
 18import sphinx
 19import sphinx_material
 20
 21# If extensions (or modules to document with autodoc) are in another directory,
 22# add these directories to sys.path here. If the directory is relative to the
 23# documentation root, use os.path.abspath to make it absolute, like shown here.
 24# sys.path.insert(0, os.path.abspath("./src"))
 25
 26project = "Python versions"
 27html_title = project
 28
 29author = f"DevOps people"
 30html_logo = "images/python-logo.png"
 31html_favicon = "images/python-logo.png"
 32release = "0.1.0"
 33now = datetime.now(tz=ZoneInfo("Europe/Paris"))
 34version = f"{now.year}-{now.month:02}-{now.day:02} {now.hour:02}H ({now.tzinfo})"
 35today = version
 36
 37extensions = [
 38    "sphinx.ext.autodoc",
 39    "sphinx.ext.doctest",
 40    "sphinx.ext.extlinks",
 41    "sphinx.ext.intersphinx",
 42    "sphinx.ext.todo",
 43    "sphinx.ext.mathjax",
 44    "sphinx.ext.viewcode",
 45    "sphinx_copybutton",
 46]
 47
 48# https://sphinx-design.readthedocs.io/en/furo-theme/get_started.html
 49extensions.append("sphinx_design")
 50
 51
 52# Add any paths that contain templates here, relative to this directory.
 53templates_path = ["_templates"]
 54exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
 55html_static_path = ["_static"]
 56html_show_sourcelink = True
 57html_sidebars = {
 58    "**": ["logo-text.html", "globaltoc.html", "localtoc.html", "searchbox.html"]
 59}
 60extensions.append("sphinx_material")
 61html_theme_path = sphinx_material.html_theme_path()
 62html_context = sphinx_material.get_html_context()
 63html_theme = "sphinx_material"
 64
 65extensions.append("sphinx.ext.intersphinx")
 66intersphinx_mapping = {
 67    "docker": ("https://gdevops.frama.io/opsindev/docker/", None),
 68    "django": ("https://gdevops.frama.io/django/tuto/", None),
 69    "documentation": ("https://gdevops.frama.io/documentation/tuto/", None),
 70    "tuto_sphinx": ("https://gdevops.frama.io/documentation/sphinx/", None),
 71    "http": ("https://gdevops.frama.io/web/tuto-http/", None),
 72    "webframeworks": ("https://gdevops.frama.io/web/frameworks/", None),
 73    "project": ("https://gdevops.frama.io/opsindev/tuto-project/", None),
 74    "webframeworks": ("https://gdevops.frama.io/web/frameworks/", None),
 75    "conda": ("https://conda.io/en/latest/", None),
 76    "sphinx": ("https://www.sphinx-doc.org/en/master", None),
 77    "tuto_python": ("https://gdevops.frama.io/python/tuto/", None),
 78}
 79extensions.append("sphinx.ext.todo")
 80todo_include_todos = True
 81
 82
 83# material theme options (see theme.conf for more information)
 84# https://gitlab.com/bashtage/sphinx-material/blob/master/sphinx_material/sphinx_material/theme.conf
 85# Colors
 86# The theme color for mobile browsers. Hex color.
 87# theme_color = #3f51b5
 88# Primary colors:
 89# red, pink, purple, deep-purple, indigo, blue, light-blue, cyan,
 90# teal, green, light-green, lime, yellow, amber, orange, deep-orange,
 91# brown, grey, blue-grey, white
 92# Accent colors:
 93# red, pink, purple, deep-purple, indigo, blue, light-blue, cyan,
 94# teal, green, light-green, lime, yellow, amber, orange, deep-orange
 95# color_accent = blue
 96# color_primary = blue-grey
 97
 98# material theme options (see theme.conf for more information)
 99html_theme_options = {
100    "base_url": "https://gdevops.frama.io/python/versions",
101    "repo_url": "https://framagit.org/gdevops/python/versions",
102    "repo_name": project,
103    "html_minify": False,
104    "html_prettify": True,
105    "css_minify": True,
106    "repo_type": "gitlab",
107    "globaltoc_depth": -1,
108    "color_primary": "green",
109    "color_accent": "cyan",
110    "theme_color": "#2196f3",
111    "nav_title": f"{project} ({today})",
112    "master_doc": False,
113    "nav_links": [
114        {
115            "href": "genindex",
116            "internal": True,
117            "title": "Index",
118        },
119        {
120            "href": "https://gdevops.frama.io/python/linkertree/",
121            "internal": False,
122            "title": "Liens Python",
123        },
124        {
125            "href": "https://gdevops.frama.io/opsindev/linkertree/",
126            "internal": False,
127            "title": "Liens devops",
128        },
129        {
130            "href": "https://gdevops.frama.io/web/linkertree/",
131            "internal": False,
132            "title": "Liens Web",
133        },
134        {
135            "href": "https://linkertree.frama.io/pvergain/",
136            "internal": False,
137            "title": "Liens pvergain",
138        },
139    ],
140    "heroes": {
141        "index": "Python versions",
142    },
143    "table_classes": ["plain"],
144}
145# https://github.com/sphinx-contrib/yasfb
146extensions.append("yasfb")
147feed_base_url = html_theme_options["base_url"]
148feed_author = "Scribe"
149# https://sphinx-design.readthedocs.io/en/furo-theme/get_started.html
150extensions.append("sphinx_design")
151# https://sphinx-tags.readthedocs.io/en/latest/quickstart.html#installation
152extensions.append("sphinx_tags")
153
154language = "en"
155html_last_updated_fmt = ""
156
157todo_include_todos = True
158
159html_use_index = True
160html_domain_indices = True
161
162copyright = f"2018-{now.year}, {author} Built with sphinx {sphinx.__version__} Python {platform.python_version()}"
163
164rst_prolog = """
165.. |FluxWeb| image:: /images/rss_avatar.webp
166"""