当尝试使用 datetime 模块时,大多数 Python 用户都遇到过需要反复猜测和检查,直到错误消失为止的情况。datetime 是那种看起来容易使用,但需要开发者深入理解一些概念的 API 之一。否则,考虑到与日期和时间相关问题的复杂性,很容易引入意想不到的错误。
时间标准
使用时间时,首先要掌握的概念是定义如何测量时间单位的标准。正如我们有测量重量或长度的标准来定义千克或米一样,我们需要一种精确的方法来定义“秒”的含义。然后,我们可以使用其他时间参考(如天、周或年),使用日历标准作为秒的倍数(例如,请参阅公历)。
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 时间)。
时区
我已经解释了什么是 UTC 以及它如何让我们定义日期和时间,但像国家/地区希望他们的本地时间中午与太阳时中午相匹配,以便太阳在下午 12 点位于天空的顶部。这就是为什么 UTC 定义了偏移量,因此我们可以将上午 12 点设置为 UTC 偏移 +4 小时。这实际上意味着没有偏移量的实际时间是上午 8 点。
政府定义了地理位置遵循的 UTC 标准偏移量,从而有效地创建了时区。最常见的时区数据库被称为 Olson 数据库。这可以使用 dateutil.tz 在 Python 中检索。
>>> 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 对象,它设置时区并在 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 有两个主要函数,可以在给定特定格式的情况下转换为字符串和从字符串转换:strftime 和 strptime。最好的方法是使用标准的 ISO_8601 来序列化与时间相关的对象作为字符串,这可以通过在 datetime 对象上调用 isoformat 来完成
>>> now = dt.datetime.now(gettz("Europe/London"))
>>> now.isoformat()
'2017-04-19T22:47:36.585205+01:00'
要从使用 isoformat 和 UTC 时区格式化的字符串中获取 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 对象及其 int 表示形式(作为 Unix 时间)之间进行转换的工具。
将当前时间作为整数获取
>>> 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 小时)。
下表总结了使用 pytz 和 dateutil 获取本地/时间戳运算的方法
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的演讲。
6 条评论