如何使用 Python 处理日期和时间

通过这篇入门文章更好地理解 Python 中的 datetime 模块。
584 位读者喜欢这篇文章。
Clocks

Matteo Ianeselli。Opensource.com 修改。CC-BY-3.0。

当尝试使用 datetime 模块时,大多数 Python 用户都遇到过这样一种情况:我们只能靠猜测和尝试,直到错误消失。 datetime 是那种看起来很容易使用,但需要开发者深入理解一些概念的 API。否则,考虑到与日期和时间相关问题的复杂性,很容易引入意想不到的 bug。

时间标准

使用时间时,首先要掌握的概念是定义如何测量时间单位的标准。就像我们有测量重量或长度的标准,定义千克或米一样,我们需要一种精确的方法来定义“”的含义。然后,我们可以使用其他时间参考——例如天、周或年——使用日历标准作为秒的倍数(例如,参见 格里高利历)。

UT1

一种最简单的测量秒的方法是将其作为一天的一部分,因为我们可以可靠地保证太阳每天都会升起和落下(在大多数地方)。这催生了世界时(UT1),是格林威治标准时间(GMT)的继承者。今天,我们使用恒星和类星体来测量地球绕太阳完成一次完整自转所需的时间。即使这看起来足够精确,但仍然存在问题;由于月球的引力、潮汐和地震,每天的长度全年都在变化。虽然这对大多数应用程序来说不是问题,但当我们需要非常精确的测量时,它就变成了一个重要的问题。GPS 三角测量就是一个对时间敏感的例子,在这种情况下,哪怕偏差一秒也会导致地球上完全不同的位置。

TAI

因此,国际原子时(TAI)被设计成尽可能精确。通过使用地球上多个实验室中的 原子钟,我们可以获得最准确和恒定的秒的测量值,从而能够以最高的精度计算时间间隔。这种精度既是福音也是诅咒,因为 TAI 非常精确,以至于它偏离了 UT1(或我们所说的民用时间)。这意味着我们的时钟正午最终会与太阳正午产生很大的偏差。

UTC

这导致了协调世界时(UTC)的开发,它汇集了这两个单位的优点。 UTC 使用 TAI 定义的秒的测量值。这允许准确测量时间,同时引入闰秒以确保时间与 UT1 的偏差不超过 0.9 秒。

这一切如何在你的电脑上运作

有了这些背景知识,你应该能够理解操作系统如何在任何给定的时刻提供时间。虽然计算机内部没有原子钟,但它使用内部时钟通过网络时间协议(NTP)与世界其他地方同步。

在类 Unix 系统中,最常见的时间测量方法是使用 POSIX 时间,它被定义为自 Unix 纪元(1970 年 1 月 1 日星期四)以来经过的秒数,不考虑闰秒。由于 POSIX 时间不处理闰秒(Python 也不处理),一些公司定义了自己的时间处理方式,通过他们的 NTP 服务器在一段时间内摊销闰秒(例如,参见 Google 时间)。

时区

Time zones maps

我已经解释了 UTC 是什么以及它如何让我们定义日期和时间,但各个国家/地区都希望他们的本地时间 noon 与太阳时的 noon 相匹配,因此太阳在下午 12 点位于天空的顶部。这就是为什么 UTC 定义了偏移量,因此我们可以设置 12 AM,UTC 偏移 +4 小时。 这实际上意味着没有偏移量的实际时间是凌晨 8 点。

政府定义了地理位置遵循的 UTC 标准偏移量,从而有效地创建了一个时区。最常见的时区数据库被称为 Olson 数据库。这可以使用 Python 中的 dateutil.tz 来检索

>>> from dateutil.tz import gettz
>>> gettz("Europe/Madrid")

gettz 的结果为我们提供了一个对象,我们可以使用该对象在 Python 中创建时区感知日期

>>> import datetime as dt
>>> dt.datetime.now().isoformat()
'2017-04-15T14:16:56.551778'  # This is a naive datetime
>>> dt.datetime.now(gettz("Europe/Madrid")).isoformat()
'2017-04-15T14:17:01.256587+02:00'  # This is a tz aware datetime, always prefer these

我们可以看到如何通过 datetime 的 now 函数获取当前时间。在第二次调用时,我们传递一个 tzinfo 对象,该对象设置时区并在该 datetime 的 ISO 字符串表示形式中显示偏移量。

如果我们只想在 Python 3 中使用纯 UTC,则不需要任何外部库

>>> dt.datetime.now(dt.timezone.utc).isoformat()
'2017-04-15T12:22:06.637355+00:00'

DST

一旦我们掌握了所有这些知识,我们可能会觉得准备好使用时区了,但我们必须意识到某些时区还会发生一件事:夏令时(DST)。

遵循 DST 的国家/地区将在春季将时钟向前拨快一小时,在秋季将时钟向后拨慢一小时,以返回到时区的标准时间。这实际上意味着单个时区可以有多个偏移量,我们可以在以下示例中看到

>>> dt.datetime(2017, 7, 1, tzinfo=dt.timezone.utc).astimezone(gettz("Europe/Madrid"))
'2017-07-01T02:00:00+02:00'
>>> dt.datetime(2017, 1, 1, tzinfo=dt.timezone.utc).astimezone(gettz("Europe/Madrid"))
'2017-01-01T01:00:00+01:00'

这导致了由 23 或 25 小时组成的一天,从而产生了非常有趣的时间算法。根据时间和时区,添加一天不一定意味着添加 24 小时

>>> today = dt.datetime(2017, 10, 29, tzinfo=gettz("Europe/Madrid"))
>>> tomorrow = today + dt.timedelta(days=1)
>>> tomorrow.astimezone(dt.timezone.utc) - today.astimezone(dt.timezone.utc)
datetime.timedelta(1, 3600)  # We've added 25 hours

使用时间戳时,最好的策略是使用非 DST 感知的时区(理想情况下为 UTC+00:00)。

序列化你的 datetime 对象

总有一天你需要以 JSON 格式发送你的 datetime 对象,你会得到以下结果

>>> now = dt.datetime.now(dt.timezone.utc)
>>> json.dumps(now)
TypeError: Object of type 'datetime' is not JSON serializable

在 JSON 中序列化 datetime 主要有三种方法

字符串

datetime 有两个主要函数可以在给定特定格式的情况下转换为字符串或从字符串转换:strftimestrptime。最好的方法是使用标准 ISO_8601 来序列化与时间相关的对象作为字符串,这可以通过调用 datetime 对象上的 isoformat 来完成

>>> now = dt.datetime.now(gettz("Europe/London"))
>>> now.isoformat()
'2017-04-19T22:47:36.585205+01:00'

要从使用带有 UTC 时区的 isoformat 格式化的字符串中获取 datetime 对象,我们可以依赖 strptime

>>> dt.datetime.strptime(now_str, "%Y-%m-%dT%H:%M:%S.%f+00:00").replace(tzinfo=dt.timezone.utc)
datetime.datetime(2017, 4, 19, 21, 49, 5, 542320, tzinfo=datetime.timezone.utc)

在此示例中,我们将偏移量硬编码为 UTC,然后在创建 datetime 对象后设置它。完全解析字符串(包括偏移量)的更好方法是使用外部库 dateutil:

>>> from dateutil.parser import parse
>>> parse('2017-04-19T21:49:05.542320+00:00')
datetime.datetime(2017, 4, 19, 21, 49, 5, 542320, tzinfo=tzutc())
>>> parse('2017-04-19T21:49:05.542320+01:00')
datetime.datetime(2017, 4, 19, 21, 49, 5, 542320, tzinfo=tzoffset(None, 3600))

请注意,一旦我们序列化和反序列化,我们将丢失时区信息,只保留偏移量。

整数

我们可以使用自特定纪元(参考日期)以来经过的秒数,将 datetime 存储为整数。正如我之前提到的,计算机系统中最著名的纪元是 Unix 纪元,它引用了自 1970 年以来的第一秒。这意味着 5 代表 1970 年 1 月 1 日的第五秒。

Python 标准库为我们提供了将当前时间作为 Unix 时间获取以及在 datetime 对象及其作为 Unix 时间的 int 表示之间进行转换的工具。

将当前时间作为整数获取

>>> import datetime as dt
>>> from dateutil.tz import gettz
>>> import time
>>> unix_time = time.time()

Unix 时间到 datetime

>>> unix_time
1492636231.597816
>>> datetime = dt.datetime.fromtimestamp(unix_time, gettz("Europe/London"))
>>> datetime.isoformat()
'2017-04-19T22:10:31.597816+01:00'

获取给定 datetime 的 Unix 时间

>>> time.mktime(datetime.timetuple())
1492636231.0
>>> # or using the calendar library
>>> calendar.timegm(datetime.timetuple())

对象

最后一个选项是将对象本身序列化为在解码时间时具有特殊含义的对象

import datetime as dt
from dateutil.tz import gettz, tzoffset

def json_to_dt(obj):
    if obj.pop('__type__', None) != "datetime":
        return obj
    zone, offset = obj.pop("tz")
    obj["tzinfo"] = tzoffset(zone, offset)
    return dt.datetime(**obj)

def dt_to_json(obj):
    if isinstance(obj, dt.datetime):
        return {
            "__type__": "datetime",
            "year": obj.year,
            "month" : obj.month,
            "day" : obj.day,
            "hour" : obj.hour,
            "minute" : obj.minute,
            "second" : obj.second,
            "microsecond" : obj.microsecond,
            "tz": (obj.tzinfo.tzname(obj), obj.utcoffset().total_seconds())
        }
    else:
        raise TypeError("Cant serialize {}".format(obj))

现在我们可以编码 JSON

>>> import json
>>> now = dt.datetime.now(dt.timezone.utc)
>>> json.dumps(now, default=dt_to_json)  # From datetime
'{"__type__": "datetime", "year": 2017, "month": 4, "day": 19, "hour": 22, "minute": 32, "second": 44, "microsecond": 778735, "tz": "UTC"}'
>>> # Also works with timezones
>>> now = dt.datetime.now(gettz("Europe/London"))
>>> json.dumps(now, default=dt_to_json)
'{"__type__": "datetime", "year": 2017, "month": 4, "day": 19, "hour": 23, "minute": 33, "second": 46, "microsecond": 681533, "tz": "BST"}'

并解码

>>> input_json='{"__type__": "datetime", "year": 2017, "month": 4, "day": 19, "hour": 23, "minute": 33, "second": 46, "microsecond": 681533, "tz": "BST"}'
>>> json.loads(input_json, object_hook=json_to_dt)
datetime.datetime(2017, 4, 19, 23, 33, 46, 681533, tzinfo=tzlocal())
>>> input_json='{"__type__": "datetime", "year": 2017, "month": 4, "day": 19, "hour": 23, "minute": 33, "second": 46, "microsecond": 681533, "tz": "EST"}'
>>> json.loads(input_json, object_hook=json_to_dt)
datetime.datetime(2017, 4, 19, 23, 33, 46, 681533, tzinfo=tzfile('/usr/share/zoneinfo/EST'))
>>> json.loads(input_json, object_hook=json_to_dt).isoformat()
'2017-04-19T23:33:46.681533-05:00'

本地时间

此后,你可能会尝试将所有 datetime 对象转换为 UTC,并且只使用 UTC datetime 和固定偏移量。即使这是迄今为止时间戳的最佳方法,但对于未来的本地时间,它很快就会中断。

我们可以区分两种主要类型的时间点:本地时间和时间戳。时间戳是通用的时间点,与任何特定位置无关。示例包括星星诞生的时间或将行记录到文件中的时间。当我们谈论“我们在挂钟上读取的时间”时,情况会发生变化。当我们说“明天 2 点见”时,我们不是指 UTC 偏移量,而是指明天下午 2 点,无论此时的偏移量是多少,都在我们当地的时区。我们不能只将这些本地时间映射到时间戳(尽管我们可以对过去的时间戳这样做),因为对于未来的事件,各国可能会更改其偏移量,这种情况发生的频率比你想象的要高。

对于这些情况,我们需要保存带有它所指的时区的 datetime,而不是偏移量。

使用 pytz 时的差异

自 Python 3.6 以来,获取 Olson 数据库的推荐库是 dateutil.tz,但过去是 pytz

它们可能看起来相似,但在某些情况下,它们处理时区的方法却大相径庭。获取当前时间也很简单


>>> import pytz
>>> dt.datetime.now(pytz.timezone("Europe/London"))
datetime.datetime(2017, 4, 20, 0, 13, 26, 469264, tzinfo=<DstTzInfo 'Europe/London' BST+1:00:00 DST>)

pytz 的一个常见陷阱是将 pytz 时区作为 datetime 的 tzinfo 属性传递


>>> dt.datetime(2017, 5, 1, tzinfo=pytz.timezone("Europe/Helsinki"))
datetime.datetime(2017, 5, 1, 0, 0, tzinfo=<DstTzInfo 'Europe/Helsinki' LMT+1:40:00 STD>)
>>> pytz.timezone("Europe/Helsinki").localize(dt.datetime(2017, 5, 1), is_dst=None)
datetime.datetime(2017, 5, 1, 0, tzinfo=<DstTzInfo 'Europe/Helsinki' EEST+3:00:00 DST>)

我们应该始终在构建的 datetime 对象上调用 localize。否则,pytz 将为时区分配它找到的第一个偏移量。

另一个主要区别可以在执行时间算法时找到。虽然我们看到添加在 dateutil 中起作用,就像我们在指定的时区中添加本地时间一样,但是当 datetime 具有 pytz tzinfo 实例时,会添加绝对小时,并且调用者必须在操作后调用 normalize,因为它不会处理 DST 更改。例如


>>> today = dt.datetime(2017, 10, 29)
>>> tz = pytz.timezone("Europe/Madrid")
>>> today = tz.localize(dt.datetime(2017, 10, 29), is_dst=None)
>>> tomorrow = today + dt.timedelta(days=1)
>>> tomorrow
datetime.datetime(2017, 10, 30, 0, 0, tzinfo=<DstTzInfo 'Europe/Madrid' CEST+2:00:00 DST>)
>>> tz.normalize(tomorrow)
datetime.datetime(2017, 10, 29, 23, 0, tzinfo=<DstTzInfo 'Europe/Madrid' CET+1:00:00 STD>)

请注意,使用 pytz tzinfo 时,它会增加 24 个绝对小时(墙上时间为 23 小时)。

下表总结了如何使用 pytzdateutil 进行墙上时间/时间戳运算:

  pytz dateutil
墙上时间 obj.tzinfo.localize(obj.replace(tzinfo=None) + timedelta, is_dst=is_dst) obj + timedelta
绝对时间 obj.tzinfo.normalize(obj + timedelta) (obj.astimezone(pytz.utc) + timedelta).astimezone(obj.tzinfo)

请注意,当发生 DST 变更时,添加墙上时间可能会导致意外结果。

最后,dateutil 可以很好地处理 PEP0495 中添加的 fold 属性,如果您使用的是早期版本的 Python,则提供向后兼容性。

快速提示

经过上述讨论,我们应该如何避免使用时间时出现的常见问题?

  • 始终使用时区。不要依赖隐式的本地时区。
  • 使用 dateutil/pytz 来处理时区。
  • 处理时间戳时,始终使用 UTC。
  • 请记住,对于某些时区,一天并不总是由 24 小时组成。
  • 保持你的时区数据库为最新。
  • 始终针对 DST 变更等情况测试您的代码。

值得一提的库

  • dateutil:用于处理时间的多种实用程序
  • freezegun:更容易测试与时间相关的应用程序
  • arrow/pendulum:标准 datetime 模块的直接替代品
  • astropy:对于天文时间和处理闰秒非常有用

Mario Corchero 将在 PyCon 2017 上发表题为It's time for datetime 的演讲,地点在俄勒冈州波特兰。

User profile image.
Mario Corchero 是彭博社的高级软件开发人员,他在那里使用 Python 和 C++ 编写小型可重用服务来自动化新闻生成并管理新闻搜索的基础架构。

6 条评论

如果您需要,我不会反对做所有这些事情,但并非所有日期任务都需要这样做。每个 Python 程序都不必是瑞士军刀。
如果您不需要时间

today = date.today()
d = today.strftime("%A, %B %d, %Y")
print d

会输出 Friday, May 12, 2017

感谢您的评论 Greg!
请注意,`date.today()` 基于您的软件运行的机器的时区来获取当前日期,我个人认为这远非理想。我建议明确指定时区,这样您就不会依赖于机器时区的配置方式。

例如
```
>>> dt.datetime.now(gettz("Australia/Melbourne")).date()
datetime.date(2017, 5, 13)
>>> dt.datetime.now(gettz("America/New_York")).date()
datetime.date(2017, 5, 12)
```

此外,并不是您的程序需要成为瑞士军刀,而是您希望您的软件无论部署在何处都能正常工作。

关于日期的字符串序列化,这实际上取决于谁/什么是消费者,但我总是建议坚持标准。

回复 作者 Greg P

很棒的总结,可以与那些说日期易于管理的人作斗争!已经添加到我的书签

很棒的文章!一个小问题 - 倒数第二个代码片段的输出,本地化的日期时间应该是 datetime(2017, 5, 1, 0, 0...月份和日期数字错误。

感谢这个很好的解释。

我不记得有多少次我对 'datetime' 包及其所有不兼容的对象(datetime、date、time 和 timedelta)感到恼火。自从我发现 'arrow' 包以来,我的生活发生了改变……从那以后我再也没有使用过基本的 'datetime' 包。

Creative Commons License本作品已根据知识共享署名-相同方式共享 4.0 国际许可协议授权。
© . All rights reserved.