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

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

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

当尝试使用 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 时间)。

时区

Time zones maps

我已经解释了什么是 UTC 以及它如何允许我们定义日期和时间,但是像国家这样的地方希望他们的本地时间中午与太阳时中午相匹配,因此太阳在下午 12 点位于天空的顶部。这就是为什么 UTC 定义了偏移量,因此我们可以拥有从 UTC 偏移 +4 小时的凌晨 12 点。这实际上意味着没有偏移量的实际时间是上午 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 对象,该对象设置时区并在该 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'

要从使用 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 小时)。

下表总结了使用 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 上发表演讲,在俄勒冈州波特兰市发表他的演讲,是时候使用 datetime 了

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

6 条评论

如果您需要这样做,我没有任何异议,但并非所有日期任务都需要这样做。每个 Python 程序都不必是瑞士军刀。
如果您不需要时间

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

给您 2017 年 5 月 12 日,星期五

感谢您的评论 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.