在本章中,我们将介绍以下配方:
- 时区感知日期时间检索当前日期时间的可靠值
- 解析日期如何根据 ISO 8601 格式解析日期
- 保存日期如何存储日期时间
- 从时间戳到日期时间与时间戳之间的转换
- 以用户格式显示日期根据我们的用户语言格式化日期
- 到明天如何计算明天的日期时间
- 下个月如何计算下个月的日期时间
- 工作日如何建立一个日期,该日期指的是该月的*nth*周一/周五
- 工作日如何在一个时间范围内获得工作日
- 将日期和时间组合在一起,将日期和时间组合成日期时间
日期是我们生活的一部分,我们习惯于把处理时间和日期作为一个基本过程。即使是小孩子也知道现在是什么时候,或者明天是什么意思。但是,试着与世界另一边的人交谈,突然之间明天、午夜等概念开始变得非常复杂。
当你说明天,你是在谈论你的明天还是我的明天?如果您计划在午夜运行一个进程,那么是哪个午夜?
为了让一切变得更困难,我们有闰秒、奇数时区、夏令时等等。当你试图在软件中接近日期时,特别是在软件作为一种可能被世界各地的人们使用的服务中,突然间,日期变得很复杂。
本章包括一些菜谱,虽然篇幅很短,但在使用用户提供的日期时,这些菜谱可以帮您省去头痛和 bug。
Python 日期时间通常是幼稚的,这意味着它们不知道所指的时区。这可能是一个大问题,因为给定日期时间,不可能知道它实际指的是什么时候。
在 Python 中处理日期时最常见的错误是试图通过datetime.datetime.now()
获取当前日期时间,因为所有datetime
方法都处理原始日期,所以不可能知道该值代表的时间。
为此配方执行以下步骤:
- 检索当前日期时间的唯一可靠方法是使用
datetime.datetime.utcnow()
。与用户所在位置和系统配置方式无关,它将始终返回 UTC 时间。因此,我们需要让 it 了解时区,以便能够将其拒绝到世界上任何时区:
import datetime
def now():
return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc)
- 一旦我们有了时区感知的当前时间,就可以将其转换为任何其他时区,这样我们就可以向用户显示他们自己时区的值:
def astimezone(d, offset):
return d.astimezone(datetime.timezone(datetime.timedelta(hours=offset)))
- 现在,鉴于我当前处于 UTC+01:00 时区,我可以获取 UTC 的当前时区感知时间,然后将其显示在我自己的时区中:
>>> d = now()
>>> print(d)
2018-03-19 21:35:43.251685+00:00
>>> d = astimezone(d, 1)
>>> print(d)
2018-03-19 22:35:43.251685+01:00
默认情况下,所有 Python datetimes 都没有指定任何时区,但是通过设置tzinfo
,我们可以让它们知道它们所指的时区。
如果我们只是抓取当前时间(datetime.datetime.now()
,我们就没有简单的方法从软件中知道我们从哪个时区抓取时间。因此,我们唯一可以依赖的时区是 UTC。无论何时检索当前时间,最好始终依赖datetime.datetime.utcnow()
。
一旦我们有了 UTC 的日期,我们知道它实际上是 UTC 时区的日期,我们就可以很容易地附加datetime.timezone.utc
时区(Python 提供的唯一一个现成的时区)并使其具有时区意识。
now
函数可以做到这一点:它获取日期时间并使其具有时区意识。
因为我们的 datetime 现在是时区感知的,从那一刻起,我们可以依靠datetime.datetime.astimezone
方法转换到我们想要的任何时区。因此,如果我们知道用户在 UTC+01:00,我们可以使用用户的本地值显示日期时间,而不是显示 UTC 值。
这正是astimezone
函数所做的。一旦提供了日期时间和 UTC 的偏移量,它将返回一个日期,该日期是指基于该偏移量的本地时区。
您可能已经注意到,虽然此解决方案可行,但它缺少更高级的功能。例如,我目前在 UTC+01:00,但根据我国的夏令时政策,我可能在 UTC+02:00。此外,我们只支持基于整数小时的补偿,虽然这是最常见的情况,但也有时区,如印度或伊朗,有半小时的补偿。
虽然我们可以扩展对时区的支持,以包括这些奇怪之处,但对于更高级的情况,您可能应该依赖pytz
包,该包为完整的 IANA 时区数据库提供时区。
当从其他软件或用户接收日期时间时,它可能是字符串格式。JSON 等格式甚至不定义日期的表示方式,但通常以 ISO8601 格式提供这些格式是最佳实践。
ISO 8601 格式通常定义为[YYYY]-[MM]-[DD]T[hh]:[mm]:[ss]+-[TZ]
,例如2018-03-19T22:00+0100
指 UTC+01:00 时区 3 月 19 日晚上 10 点。
ISO 8601 传递了表示日期和时间所需的所有信息,因此它是封送日期时间并通过网络发送的好方法。
遗憾的是,它有很多奇怪之处(例如,+00
时区也可以写为Z
,或者您可以在小时、分钟和秒之间省略:
,因此解析它有时可能会带来麻烦。
以下是要遵循的步骤:
- 由于 ISO 8601 允许的所有变体,没有简单的方法可以将其扔到
datetime.datetime.strptime
并为所有情况返回日期时间;我们必须将所有可能的格式合并为一个格式,然后解析该格式:
import datetime
def parse_iso8601(strdate):
date, time = strdate.split('T', 1)
if '-' in time:
time, tz = time.split('-')
tz = '-' + tz
elif '+' in time:
time, tz = time.split('+')
tz = '+' + tz
elif 'Z' in time:
time = time[:-1]
tz = '+0000'
date = date.replace('-', '')
time = time.replace(':', '')
tz = tz.replace(':', '')
return datetime.datetime.strptime('{}T{}{}'.format(date, time, tz),
"%Y%m%dT%H%M%S%z")
parse_iso8601
之前的实现处理了最可能的 ISO 8601 表示:
>>> parse_iso8601('2018-03-19T22:00Z')
datetime.datetime(2018, 3, 19, 22, 0, tzinfo=datetime.timezone.utc)
>>> parse_iso8601('2018-03-19T2200Z')
datetime.datetime(2018, 3, 19, 22, 0, tzinfo=datetime.timezone.utc)
>>> parse_iso8601('2018-03-19T22:00:03Z')
datetime.datetime(2018, 3, 19, 22, 0, 3, tzinfo=datetime.timezone.utc)
>>> parse_iso8601('20180319T22:00:03Z')
datetime.datetime(2018, 3, 19, 22, 0, 3, tzinfo=datetime.timezone.utc)
>>> parse_iso8601('20180319T22:00:03+05:00')
datetime.datetime(2018, 3, 19, 22, 0, 3, tzinfo=datetime.timezone(datetime.timedelta(0, 18000)))
>>> parse_iso8601('20180319T22:00:03+0500')
datetime.datetime(2018, 3, 19, 22, 0, 3, tzinfo=datetime.timezone(datetime.timedelta(0, 18000)))
parse_iso8601
的基本思想是,在解析 ISO 8601 之前,无论接收到什么方言,我们都会将其转换为[YYYY][MM][DD]T[hh][mm][ss]+-[TZ]
的形式。
最难的部分是检测时区,因为时区可以用+
、-
分隔,甚至可以是Z
。一旦提取了时区,我们就可以去掉日期中的所有-
实例和时间中的所有:
实例。
请注意,在提取时区之前,我们将时间与日期分开,因为日期和时区都可能包含-
字符,我们不希望解析器混淆。
解析日期可能变得非常复杂。虽然我们的parse_iso8601
在与大多数以字符串格式(如 JSON)提供日期的系统交互时都会起作用,但您很快就会遇到由于日期时间的各种表达方式而无法满足要求的情况。
例如,我们可能会收到一个值,如2 weeks ago
或July 4, 2013 PST
。试图解析所有这些案例并不十分方便,而且很快就会变得复杂。如果您必须处理这些特殊情况,您可能需要依赖外部软件包,如dateparser
、dateutil
或moment
。
迟早,我们都必须将日期保存在某个地方,将其发送到数据库或保存到文件中。也许我们将把它转换成 JSON,发送到另一个软件。
许多数据库系统不跟踪时区。其中一些服务器有一个配置选项,说明它们应该使用哪个时区,但在大多数情况下,您提供的日期将按原样保存。
在许多情况下,这会导致意外的错误或行为。假设您是一名优秀的童子军,并且正确地完成了接收保留时区的日期时间所需的所有工作。现在您的 datetime 为2018-01-15 15:30:00 UTC+01:00
,并且,一旦您将其存储在数据库中,UTC+01:00
将很容易丢失,即使您自己将其存储在文件中,存储和恢复时区通常是一项麻烦的工作。
因此,在将日期时间存储到某个位置之前,您应该始终确保将日期时间转换为 UTC,这将始终保证,无论日期时间来自哪个时区,当您重新加载它时,它始终代表正确的时间。
此配方的步骤如下所示:
- 为了保存 datetime,我们需要一个函数,确保 datetime 在实际存储之前始终引用 UTC:
import datetime
def asutc(d):
return d.astimezone(datetime.timezone.utc)
asutc
功能可用于任何日期时间,以确保在实际存储之前将其移动到 UTC:
>>> now = datetime.datetime.now().replace(
... tzinfo=datetime.timezone(datetime.timedelta(hours=1))
... )
>>> now
datetime.datetime(2018, 3, 22, 0, 49, 45, 198483,
tzinfo=datetime.timezone(datetime.timedelta(0, 3600)))
>>> asutc(now)
datetime.datetime(2018, 3, 21, 23, 49, 49, 742126, tzinfo=datetime.timezone.utc)
此配方的功能非常简单,通过datetime.datetime.astimezone
方法,日期始终转换为 UTC 表示形式。
这确保了它既适用于存储跟踪时区的情况(因为日期仍然可以识别时区,但时区将是 UTC),也适用于存储不保留时区的情况(因为没有时区的 UTC 日期仍然表示相同的 UTC 日期,就像增量为零一样)。
时间戳是从特定时刻开始以秒数表示的日期。通常,由于计算机所能代表的数值大小有限,通常从 1970 年 1 月 1 日开始计算。
如果您曾经收到过一个值,例如1521588268
作为日期时间表示,您可能想知道如何将其转换为实际的日期时间。
最新的 Python 版本引入了一种从时间戳快速来回转换日期时间的方法:
>>> import datetime
>>> ts = 1521588268
>>> d = datetime.datetime.utcfromtimestamp(ts)
>>> print(repr(d))
datetime.datetime(2018, 3, 20, 23, 24, 28)
>>> newts = d.timestamp()
>>> print(newts)
1521584668.0
正如配方介绍中指出的,对于计算机来说,数字的大小是有限制的。因此,需要注意的是,datetime.datetime
实际上可以表示任何日期,但时间戳不能。
例如,尝试表示从1300
开始的日期时间将成功,但无法将其转换为时间戳:
>>> datetime.datetime(1300, 1, 1)
datetime.datetime(1300, 1, 1, 0, 0)
>>> datetime.datetime(1300, 1, 1).timestamp()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
OverflowError: timestamp out of range
时间戳只能表示从 1970 年 1 月 1 日开始的日期。
对于遥远的日期,反向也是如此,而253402214400
表示 9999 年 12 月 31 日的时间戳,尝试从晚于该值的日期创建日期时间将失败:
>>> datetime.datetime.utcfromtimestamp(253402214400)
datetime.datetime(9999, 12, 31, 0, 0)
>>> datetime.datetime.utcfromtimestamp(253402214400+(3600*24))
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: year is out of range
datetime 只能表示从 1 年到 9999 年的日期。
当显示软件中的日期时,如果用户不知道您将使用的格式,则很容易混淆用户。
我们已经知道,时区扮演着重要的角色,在显示时间时,我们总是希望将其显示为具有时区意识的时间,但即使是日期也可能有其模糊性。如果你写 2018 年 3 月 4 日,是 4 月 3 日日还是 3 月 4 日日?
因此,您通常有两种选择:
- 采用国际格式(2018-04-03)
- 本地化日期(2018 年 4 月 3 日)
如果可能的话,将日期格式本地化显然更好,这样我们的用户将看到一个他们可以轻松识别的值。
此配方需要以下步骤:
- Python 标准库中的
locale
模块提供了一种获取系统支持的本地化格式的方法。通过使用它,我们可以以目标系统允许的任何方式格式化日期:
import locale
import contextlib
@contextlib.contextmanager
def switchlocale(name):
prev = locale.getlocale()
locale.setlocale(locale.LC_ALL, name)
yield
locale.setlocale(locale.LC_ALL, prev)
def format_date(loc, d):
with switchlocale(loc):
fmt = locale.nl_langinfo(locale.D_T_FMT)
return d.strftime(fmt)
- 调用
format_date
将正确地将输出作为预期locale
模块中日期的字符串表示:
>>> format_date('de_DE', datetime.datetime.utcnow())
'Mi 21 Mär 00:08:59 2018'
>>> format_date('en_GB', datetime.datetime.utcnow())
'Wed 21 Mar 00:09:11 2018'
format_date
功能分为两个主要部分。
第一个是由switchlocale
上下文管理器提供的,它负责启用请求的locale
(区域设置是进程范围的),将控制权返还给包装好的代码块,然后恢复原始locale
。这样,我们只能在上下文管理器中使用请求的locale
,而不会影响我们软件的任何其他部分。
第二个是在上下文管理器本身中发生的事情。使用locale.nl_langinfo
向当前启用的locale
请求日期和时间格式字符串(locale.D_T_FMT
。这将返回一个字符串,告诉我们如何在当前活动的locale
中格式化日期时间。返回的字符串类似于'%a %e %b %X %Y'
。
然后通过datetime.strftime
根据检索到的格式字符串对日期本身进行格式化。
请注意,返回的字符串通常包含%a
和%b
格式化程序,它们表示当前工作日和当前月份的名称。随着每种语言的工作日或月份名称的更改,Python 解释器将在当前启用的locale
中发出工作日或月份的名称。
因此,我们不仅按照用户期望的方式格式化了日期,而且生成的输出也将使用用户的语言。
虽然这个解决方案似乎非常方便,但需要注意的是,它依赖于动态切换locale
。
切换locale
是一个非常昂贵的操作,因此,如果要格式化很多值(例如for
循环或数千个日期),则可能太慢。
此外,切换locale
也不是线程安全的,因此您将无法在多线程软件中应用此方法,除非所有的locale
切换都发生在其他线程启动之前。
如果您希望以健壮且线程安全的方式处理本地化,那么您可能需要检查 babel 包。Babel 支持日期和数字的本地化,其工作方式不需要设置全局状态,因此即使在线程环境中也能正常工作。
当你有一个约会时,通常需要对那个日期进行数学运算。例如,也许你想搬到明天或昨天。
Datetimes 支持数学运算,例如对其进行加法或减法运算,但当涉及到时间时,很难获得移动到下一天或前一天所需的精确加减秒数。
出于这个原因,这个食谱将展示一个简单的方法,从任何给定的日期移动到下一天或前一天。
对于此配方,以下是步骤:
shiftdate
功能允许我们将日期移动任意天数:
import datetime
def shiftdate(d, days):
return (
d.replace(hour=0, minute=0, second=0, microsecond=0) +
datetime.timedelta(days=days)
)
- 使用它非常简单,只需提供要添加或删除的天数:
>>> now = datetime.datetime.utcnow()
>>> now
datetime.datetime(2018, 3, 21, 21, 55, 5, 699400)
- 我们可以用它去明天:
>>> shiftdate(now, 1)
datetime.datetime(2018, 3, 22, 0, 0)
- 或者去昨天:
>>> shiftdate(now, -1)
datetime.datetime(2018, 3, 20, 0, 0)
- 甚至进入下个月:
>>> shiftdate(now, 11)
datetime.datetime(2018, 4, 1, 0, 0)
通常,当移动日期时间时,我们想要的是转到一天的开始。假设您希望从事件列表中查找明天发生的所有事件,您确实希望搜索day_after_tomorrow > event_time >= tomorrow
,因为您希望查找从明天午夜到后天午夜发生的所有事件。
因此,仅仅改变一天本身是行不通的,因为我们的日期时间也有一个与之相关的时间。如果我们只在日期上加上一天,我们实际上会在明天包含的小时范围内的某个地方结束。
这就是为什么shiftdate
函数总是将提供日期的时间替换为午夜的原因。
一旦日期移到午夜,我们只需添加一个等于指定天数的timedelta
。如果这个数字是负数,我们将把时间移回D + -1 == D -1
。
移动日期时的另一个常见需求是能够将日期移动到下一个月或上一个月。
如果您阅读了走向明天食谱,您将看到与此食谱的许多相似之处,尽管在处理月份时需要进行一些额外的更改,而在处理天数时则不需要,因为月份的持续时间是可变的。
为此配方执行以下步骤:
shiftmonth
功能允许我们将日期前后移动任意个月:
import datetime
def shiftmonth(d, months):
for _ in range(abs(months)):
if months > 0:
d = d.replace(day=5) + datetime.timedelta(days=28)
else:
d = d.replace(day=1) - datetime.timedelta(days=1)
d = d.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
return d
- 使用它非常简单,只需提供要添加或删除的月份:
>>> now = datetime.datetime.utcnow()
>>> now
datetime.datetime(2018, 3, 21, 21, 55, 5, 699400)
- 我们可以使用它进入下个月:
>>> shiftmonth(now, 1)
datetime.datetime(2018, 4, 1, 0, 0)
- 或者回到上个月:
>>> shiftmonth(now, -1)
datetime.datetime(2018, 2, 1, 0, 0)
- 甚至可以移动任意个月:
>>> shiftmonth(now, 10)
datetime.datetime(2019, 1, 1, 0, 0)
如果你试着将这个食谱和明天的食谱进行比较,你会注意到这个食谱变得更加复杂,尽管它的目的非常相似。
正如当我们在一天中的某个特定时间点(通常是开始)移动时,我们感兴趣的是在一天中的某个特定时间点移动一样,当移动月份时,我们不希望在新月份的某个随机日期和时间结束。
这就解释了我们配方的最后一部分,对于由数学表达式生成的任何日期时间,我们将时间重置为每月第一天的午夜:
d = d.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
与 days 配方一样,这允许我们检查条件,例如two_month_from_now > event_date >= next_month
,因为我们将捕获从第一天午夜到最后一天 23:59 的所有事件。
您可能想知道的部分是for
循环。
与我们必须以天为单位移动(所有移动的持续时间均为 24 小时)和以月为单位移动不同,我们需要考虑这样一个事实,即每个移动的持续时间都不同。
这就是为什么在向前推进时,我们将当前日期设置为该月的第 5个,然后再添加 28 天。单独增加 28 天是不够的,因为它只适用于 2 月份,如果你想知道,增加 31 天也不起作用,因为在 2 月份的情况下,你将移动两个月而不是一个月。
这就是为什么我们将当前日期设置为本月的第 5个,因为我们希望选择一个日期,从中我们可以确定,增加 28 天将使我们进入下个月。
所以,举例来说,选择本月的 1st是可行的,因为 3 月 1st+28 天=3 月 29 日th,所以我们还是在 3 月。而 3 月 5 日日+28 天=4 月 2 日日、4 月 5 日日+28 天=5 月 3 日日、2 月 5 日日+28 天=3 月 5 日日。因此,对于任何给定的月份,我们总是在第 5个月的基础上增加 28 天,进入下一个月。
事实上,我们总是去另一个不同的日子并不重要,因为那一天总是会被本月的 1st所取代。
由于没有固定的天数可以保证我们总是准确地进入下个月,我们不能仅仅通过添加days * months
来移动,因此我们必须在for
循环中这样做,并持续移动months
次进入下个月。
搬回去后,事情变得容易多了。因为所有的月份都是从一月份的第一天开始的,所以我们可以移动到那里,然后减去一天。我们将永远停留在上个月的最后一天。
为本月 20日或本月 3日周确定日期非常简单,但如果必须为本月 3日周一确定日期,该怎么办?
完成以下步骤:
- 为了解决此问题,我们将实际生成与请求的工作日匹配的所有月日:
import datetime
def monthweekdays(month, weekday):
now = datetime.datetime.utcnow()
d = now.replace(day=1, month=month, hour=0, minute=0, second=0,
microsecond=0)
days = []
while d.month == month:
if d.isoweekday() == weekday:
days.append(d)
d += datetime.timedelta(days=1)
return days
- 然后,一旦我们有了一个列表,抓取*nth*天只需对结果列表进行索引即可。例如,从三月开始抓取周一:
>>> monthweekdays(3, 1)
[datetime.datetime(2018, 3, 5, 0, 0),
datetime.datetime(2018, 3, 12, 0, 0),
datetime.datetime(2018, 3, 19, 0, 0),
datetime.datetime(2018, 3, 26, 0, 0)]
- 因此,抓住 3 月的 3rd周一将是:
>>> monthweekdays(3, 1)[2]
datetime.datetime(2018, 3, 19, 0, 0)
在菜谱的开头,我们为请求月份的第一天创建一个日期。然后我们一天一天向前走,直到这个月结束,我们留出所有符合要求的工作日的天数。
工作日从周一的一天到周日的七天。
一旦我们有了所有的周一、周五或一个月中的任何一天,我们就可以对结果列表进行索引,只获取我们真正感兴趣的那些。
在许多管理应用程序中,你只需要考虑工作日,星期六和星期日就不重要了。在那些日子里你没有工作,所以从工作的角度来看,他们不存在。
因此,当计算项目管理或工作相关应用程序的给定时间跨度中包含的天数时,您可以忽略这些天数。
我们希望获取两个日期之间的天数列表,只要它们是工作日:
def workdays(d, end, excluded=(6, 7)):
days = []
while d.date() < end.date():
if d.isoweekday() not in excluded:
days.append(d)
d += datetime.timedelta(days=1)
return days
例如,如果是 2018 年 3 月 22 日和,这是一个周四,我想知道截至下周一的工作日(这是 3 月 26 日和,我可以很容易地要求workdays
:
>>> workdays(datetime.datetime(2018, 3, 22), datetime.datetime(2018, 3, 26))
[datetime.datetime(2018, 3, 22, 0, 0),
datetime.datetime(2018, 3, 23, 0, 0)]
所以我们知道还有两天:周四和周五。
如果你在世界上的某个地方,周日工作,周五可能不工作,excluded
参数可以用来表示哪些天应该排除在工作日之外。
配方非常简单,我们只需从提供的日期(d
开始,一次添加一天,循环直到end
。
我们认为所提供的参数是 DATESTEVE,因此我们只比较日期,因为我们不想随机地包含和排除最后一天,这取决于在 ToalT0T 和胡 T1 中提供的时间。
这允许datetime.datetime.utcnow()
向我们提供第一个参数,而不必关心函数何时被调用。只比较日期本身,不比较时间。
有时你会有不同的日期和时间。当用户输入它们时,这种情况尤其频繁。从互动的角度来看,选择一个日期然后选择一个时间通常比一起选择一个日期和时间更容易。或者您可能正在合并来自两个不同来源的输入。
在所有这些情况下,您最终将得到一个日期和时间,您希望将其合并到一个datetime.datetime
实例中。
Python 标准库提供了对这种开箱即用操作的支持,因此具有以下任意两种功能:
>>> t = datetime.time(13, 30)
>>> d = datetime.date(2018, 1, 11)
我们可以轻松地将它们组合成一个实体:
>>> datetime.datetime.combine(d, t)
datetime.datetime(2018, 1, 11, 13, 30)
如果您的time
实例有一个时区(tzinfo
,那么将日期与时间组合也会保留它:
>>> t = datetime.time(13, 30, tzinfo=datetime.timezone.utc)
>>> datetime.datetime.combine(d, t)
datetime.datetime(2018, 1, 11, 13, 30, tzinfo=datetime.timezone.utc)
如果您的时间没有时区,则在组合两个值时仍可以指定一个时区:
>>> t = datetime.time(13, 30)
>>> datetime.datetime.combine(d, t, tzinfo=datetime.timezone.utc)
只有 Python 3.6+支持在组合时提供时区。如果您使用的是以前的 Python 版本,则必须将时区设置为时间值。