Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 615 – 标准库中对 IANA 时区数据库的支持

作者:
Paul Ganssle <paul at ganssle.io>
讨论至:
Discourse 帖子
状态:
最终版
类型:
标准跟踪
创建日期:
2020年2月22日
Python 版本:
3.9
发布历史:
2020年2月25日,2020年3月29日
取代:
431

目录

重要

本 PEP 是一份历史文档。最新、规范的文档现可在 zoneinfo 找到。

×

有关如何提出更改,请参阅 PEP 1

摘要

本提案建议添加一个模块 zoneinfo,以提供支持 IANA 时区数据库的具体时区实现。默认情况下,如果系统时区数据可用,zoneinfo 将使用系统时区数据;如果系统时区数据不可用,该库将回退到使用 PyPI 上部署的第一方包 tzdata[d]

动机

datetime 库使用灵活的机制来处理时区:所有转换和时区信息查询都委托给抽象 datetime.tzinfo 基类的子类实例。[10] 这允许用户实现任意复杂的时区规则,但实际上大多数用户只需要支持三种类型的时区:[a]

  1. UTC 及其固定偏移量
  2. 系统本地时区
  3. IANA 时区

在 Python 3.2 中,引入了 datetime.timezone 类来支持第一类时区(其中 UTC 有一个特殊的 datetime.timezone.utc 单例)。

虽然仍然没有“本地”时区,但在 Python 3.0 中,朴素时区的语义被改变以支持许多“本地时间”操作,现在可以从本地时间获取固定时区偏移量。

>>> print(datetime(2020, 2, 22, 12, 0).astimezone())
2020-02-22 12:00:00-05:00
>>> print(datetime(2020, 2, 22, 12, 0).astimezone()
...       .strftime("%Y-%m-%d %H:%M:%S %Z"))
2020-02-22 12:00:00 EST
>>> print(datetime(2020, 2, 22, 12, 0).astimezone(timezone.utc))
2020-02-22 17:00:00+00:00

然而,目前仍然不支持 IANA 时区数据库(也称为“tz”数据库或 Olson 数据库 [6])中描述的时区。时区数据库是公共领域的,并被广泛分发——它默认存在于许多类 Unix 操作系统上。该数据库的稳定性得到了极大的关注:IETF 有关于维护程序(RFC 6557)和已编译二进制 (TZif) 格式(RFC 8536)的 RFC。因此,即使标准库发布的节奏相对较长,添加对 IANA 数据库编译输出的支持也可能会给最终用户带来巨大价值。

提案

本 PEP 有三个主要关注点

  1. zoneinfo.ZoneInfo 类的语义(zoneinfo-class
  2. 使用的时区数据源(data-sources
  3. 时区搜索路径的配置选项(search-path-config

由于提案的复杂性,设计决策和基本原理按主题分组,而不是设置单独的“规范”和“基本原理”部分。

zoneinfo.ZoneInfo

构造函数

zoneinfo.ZoneInfo 类的初始设计有几个构造函数。

ZoneInfo(key: str)

主构造函数接受一个参数 key,它是一个字符串,表示系统时区数据库中时区文件的名称(例如 "America/New_York", "Europe/London"),并返回从搜索路径上第一个匹配数据源构建的 ZoneInfo 对象(有关更多详细信息,请参阅数据源部分)。所有时区信息必须在构造时从数据源(通常是 TZif 文件)急切读取,并且在对象的生命周期内不得更改(此限制适用于所有 ZoneInfo 构造函数)。

如果在搜索路径上找不到匹配的文件(无论是由于系统未提供时区数据还是由于键无效),构造函数将引发 zoneinfo.ZoneInfoNotFoundError,它将是 KeyError 的子类。

此构造函数做出的一项不寻常的保证是,使用相同参数的调用必须返回 相同 的对象。具体来说,对于 key 的所有值,以下断言必须始终有效 [b]

a = ZoneInfo(key)
b = ZoneInfo(key)
assert a is b

其原因在于,datetime 操作(例如比较、算术)的语义取决于所涉及的 datetime 是否表示相同或不同的时区;两个 datetime 仅在 dt1.tzinfo is dt2.tzinfo 时才处于同一时区。[1] 除了避免不必要地创建 ZoneInfo 对象带来的适度性能优势外,提供此保证应能最大程度地减少最终用户的意外行为。

dateutil.tz.gettz 自 2.7.0 版(2018 年 3 月发布)以来提供了类似的保证。[16]

注意

实现可以决定如何实现缓存行为,但此处做出的保证只要求只要存在对相同构造函数调用结果的两个引用,它们就必须是对同一对象的引用。这与引用计数缓存一致,在该缓存中,当没有引用 ZoneInfo 对象时,它们将被逐出(例如,使用 weakref.WeakValueDictionary 实现的缓存)——允许但不要求或不建议使用“强”缓存来实现这一点,其中所有 ZoneInfo 对象都被无限期地保持活动状态。

ZoneInfo.no_cache(key: str)

这是一个替代构造函数,它绕过了构造函数的缓存。它与主构造函数相同,但每次调用都会返回一个新对象。这可能主要用于测试目的,或者故意在具有相同名义时区的 datetime 之间引起“不同时区”语义。

即使通过此方法构造的对象是缓存未命中,也不得将其输入到缓存中;换句话说,以下断言应始终为真

>>> a = ZoneInfo.no_cache(key)
>>> b = ZoneInfo(key)
>>> a is not b
ZoneInfo.from_file(fobj: IO[bytes], /, key: str = None)

这是一个替代构造函数,允许从任何 TZif 字节流构造 ZoneInfo 对象。此构造函数接受一个可选参数 key,该参数设置时区的名称,用于 __str____repr__(参见表示)。

与主构造函数不同,这始终构造一个新对象。这种偏离主构造函数缓存行为的原因有二:流对象具有可变状态,因此确定两个输入是否相同是困难或不可能的,并且用户从文件构造时很可能明确希望从该文件而不是缓存加载。

ZoneInfo.no_cache 一样,通过此方法构造的对象不得添加到缓存中。

数据更新期间的行为

重要的是,给定 ZoneInfo 对象的行为在其生命周期内不得更改,因为 datetimeutcoffset() 方法用于其相等和哈希计算,如果结果在 datetime 的生命周期内发生变化,它可能会破坏所有可哈希对象的不变性 [3] [4],即如果 x == y,则 hash(x) == hash(y) 也必须为真 [c]

考虑到 datetime 不变性的维护以及主构造函数在用相同参数调用时始终返回相同对象的约定,如果在解释器运行期间时区数据源被更新,它不得使任何缓存失效或修改任何现有 ZoneInfo 对象。然而,新构造的 ZoneInfo 对象应来自更新后的数据源。

这意味着 ZoneInfo 构造函数的新调用更新数据源的时间点主要取决于缓存行为的语义。从更新后的数据源获取 ZoneInfo 对象的唯一保证方法是导致缓存未命中,方法是绕过缓存并使用 ZoneInfo.no_cache 或清除缓存。

注意

指定的缓存行为不要求缓存惰性填充——它与规范一致(尽管不推荐)急切地用从未构造过的时区预填充缓存。

主动缓存失效

除了允许用户 绕过 缓存的 ZoneInfo.no_cache 之外,ZoneInfo 还公开了一个 clear_cache 方法,以故意使整个缓存或缓存的选择性部分失效

ZoneInfo.clear_cache(*, only_keys: Iterable[str]=None) -> None

如果没有传递任何参数,所有缓存都会失效,并且在缓存清除后,对主 ZoneInfo 构造函数对每个键的第一次调用将返回一个新实例。

>>> NYC0 = ZoneInfo("America/New_York")
>>> NYC0 is ZoneInfo("America/New_York")
True
>>> ZoneInfo.clear_cache()
>>> NYC1 = ZoneInfo("America/New_York")
>>> NYC0 is NYC1
False
>>> NYC1 is ZoneInfo("America/New_York")
True

一个可选参数 only_keys 接受一个要从缓存中清除的键的可迭代对象,否则保持缓存不变。

>>> NYC0 = ZoneInfo("America/New_York")
>>> LA0 = ZoneInfo("America/Los_Angeles")
>>> ZoneInfo.clear_cache(only_keys=["America/New_York"])
>>> NYC1 = ZoneInfo("America/New_York")
>>> LA0 = ZoneInfo("America/Los_Angeles")
>>> NYC0 is NYC1
False
>>> LA0 is LA1
True

缓存行为的操作预计是一个小众用例;此函数主要用于方便测试,并允许具有特殊需求的用户根据其需求调整缓存失效行为。

字符串表示

ZoneInfo 类的 __str__ 表示将取自 key 参数。这部分是因为 key 代表字符串的人类可读“名称”,但也是因为它是一个用户希望公开的有用参数。有必要提供一种机制来公开键以进行语言之间的序列化,并且因为它也是 CLDR(Unicode Common Locale Data Repository [5])等本地化项目的主键。

一个例子

>>> zone = ZoneInfo("Pacific/Kwajalein")
>>> str(zone)
'Pacific/Kwajalein'

>>> dt = datetime(2020, 4, 1, 3, 15, tzinfo=zone)
>>> f"{dt.isoformat()} [{dt.tzinfo}]"
'2020-04-01T03:15:00+12:00 [Pacific/Kwajalein]'

当未指定 key 时,str 操作不应失败,而应返回对象的 __repr__

>>> zone = ZoneInfo.from_file(f)
>>> str(zone)
'ZoneInfo.from_file(<_io.BytesIO object at ...>)'

ZoneInfo__repr__ 是实现定义的,并且在版本之间不一定稳定,但它不能是有效的 ZoneInfo 键,以避免键派生的 ZoneInfo 与有效 __str__ 和文件派生的 ZoneInfo(已回退到 __repr__)之间的混淆。

由于使用 str() 访问键无法轻松检查键是否存在(唯一的方法是尝试从中构造 ZoneInfo 并检测它是否引发异常),ZoneInfo 对象还将公开一个只读 key 属性,如果未提供键,该属性将为 None

Pickle 序列化

ZoneInfo 对象将按键进行序列化,而不是序列化所有转换数据,并且从原始文件构造的 ZoneInfo 对象(即使指定了 key 值)也无法进行 pickle 序列化。

ZoneInfo 对象的行为取决于其构造方式

  1. ZoneInfo(key):当使用主构造函数构造时,ZoneInfo 对象将按键序列化,并且在反序列化时将在反序列化过程中使用主构造函数,因此预计与对同一时区的其他引用是相同的对象。例如,如果 europe_berlin_pkl 是一个包含从 ZoneInfo("Europe/Berlin") 构造的 pickle 的字符串,则预期行为如下
    >>> a = ZoneInfo("Europe/Berlin")
    >>> b = pickle.loads(europe_berlin_pkl)
    >>> a is b
    True
    
  2. ZoneInfo.no_cache(key):当从绕过缓存的构造函数构造时,ZoneInfo 对象仍将按键序列化,但在反序列化时,它将使用绕过缓存的构造函数。如果 europe_berlin_pkl_nc 是一个包含从 ZoneInfo.no_cache("Europe/Berlin") 构造的 pickle 的字符串,则预期行为如下
    >>> a = ZoneInfo("Europe/Berlin")
    >>> b = pickle.loads(europe_berlin_pkl_nc)
    >>> a is b
    False
    
  3. ZoneInfo.from_file(fobj, /, key=None):当从文件构造时,ZoneInfo 对象在 pickle 序列化时将引发异常。如果最终用户希望对从文件构造的 ZoneInfo 进行 pickle 序列化,建议他们使用包装类型或自定义序列化函数:要么按键序列化,要么存储文件对象的内容并序列化该内容。

这种序列化方法要求序列化和反序列化端都提供所需键的时区数据,类似于类和函数引用在序列化和反序列化环境中都应存在的方式。这也意味着,当对在具有不同版本时区数据的环境中序列化的 ZoneInfo 进行反序列化时,不保证结果的一致性。

时区数据来源

IANA 时区支持面临的最困难挑战之一是保持数据最新;在 1997 年到 2020 年期间,每年发布 3 到 21 个版本,通常是为了应对几乎没有或根本没有通知的时区规则变化(有关更多详细信息,请参阅 [7])。为了保持最新并让系统管理员控制数据源,我们建议尽可能使用系统部署的时区数据。然而,并非所有系统都附带公开可用的时区数据库——值得注意的是 Windows 使用不同的系统来管理时区——因此如果可用,zoneinfo 会回退到可安装的第一方包 tzdata,可在 PyPI 上获取。[d] 如果未找到系统 zoneinfo 文件但安装了 tzdata,则主 ZoneInfo 构造函数将使用 tzdata 作为时区源。

系统时区信息

许多类 Unix 系统默认部署时区数据,或提供规范的时区数据包(通常称为 tzdata,就像在 Arch Linux、Fedora 和 Debian 上一样)。只要有可能,最好遵循系统时区信息,因为这允许所有语言栈的时区信息在一个地方更新和维护。鼓励 Python 分发商确保在可能的情况下(例如,通过将 tzdata 声明为 python 包的依赖项)与 Python 一起安装时区数据。

zoneinfo 模块将使用一种“搜索路径”策略,类似于 PATH 环境变量或 Python 中的 sys.path 变量;zoneinfo.TZPATH 变量将是只读的(有关更多详细信息,请参阅搜索路径配置),是时区数据位置的有序列表。当从键创建 ZoneInfo 实例时,时区文件将从路径上存在该键的第一个数据源构造,例如,如果 TZPATH

TZPATH = (
    "/usr/share/zoneinfo",
    "/etc/zoneinfo"
    )

并且(尽管这非常不寻常)/usr/share/zoneinfo 只包含 America/New_York,而 /etc/zoneinfo 包含 America/New_YorkEurope/Moscow,那么 ZoneInfo("America/New_York") 将由 /usr/share/zoneinfo/America/New_York 满足,而 ZoneInfo("Europe/Moscow") 将由 /etc/zoneinfo/Europe/Moscow 满足。

目前,在 Windows 系统上,搜索路径将默认为空,因为 Windows 不官方提供时区数据库的副本。在非 Windows 系统上,搜索路径将默认为最常见的搜索路径列表。尽管这在未来版本中可能会改变,但发布时默认搜索路径将是

TZPATH = (
    "/usr/share/zoneinfo",
    "/usr/lib/zoneinfo",
    "/usr/share/lib/zoneinfo",
    "/etc/zoneinfo",
)

这可以在编译时或运行时配置;有关配置选项的更多信息,请参阅搜索路径配置

tzdata Python 包

为了确保所有最终用户都能轻松访问时区数据,本 PEP 提议创建一个纯数据包 tzdata,作为系统数据不可用时的备用方案。tzdata 包将作为“第一方”包 [d] 在 PyPI 上分发,由 CPython 开发团队维护。

tzdata 包仅包含数据和元数据,不包含任何面向公众的函数或类。它将被设计为与较新的 importlib.resources [11] 访问模式和较旧的访问模式(如 pkgutil.get_data [12])兼容。

虽然 tzdata 包明确设计用于 CPython,但它本身是一个公共包,可作为第三方 Python 包的“官方”时区数据源。

搜索路径配置

时区搜索路径非常依赖于系统,有时甚至依赖于应用程序,因此提供自定义选项是有意义的。本 PEP 提供了三个这样的自定义途径

  1. 通过编译时选项进行全局配置
  2. 通过环境变量进行每次运行配置
  3. 通过 reset_tzpath 函数进行运行时配置更改

在所有配置方法中,搜索路径必须仅包含绝对路径,而不是相对路径。如果发现除绝对路径之外的字符串,实现可以选择忽略、警告或引发异常(并且可以根据上下文做出不同的选择——例如,当无效路径传递给 reset_tzpath 时引发异常,但在环境变量中包含无效路径时发出警告)。如果未引发异常,则任何除绝对路径之外的字符串都不得包含在时区搜索路径中。

编译时选项

下游分发商最有可能确切知道他们的系统时区数据部署在哪里,因此将提供一个编译时选项 PYTHONTZPATH 来设置默认搜索路径。

PYTHONTZPATH 选项应该是一个由 os.pathsep 分隔的字符串,列出时区数据可能部署的位置(例如 /usr/share/zoneinfo)。

环境变量

在初始化 TZPATH 时(以及每次调用不带参数的 reset_tzpath 时),zoneinfo 模块将使用环境变量 PYTHONTZPATH(如果存在)来设置搜索路径。

PYTHONTZPATH 是一个由 os.pathsep 分隔的字符串,它 替换 (而不是增加)默认时区路径。以下是一些提议语义的示例

$ python print_tzpath.py
("/usr/share/zoneinfo",
 "/usr/lib/zoneinfo",
 "/usr/share/lib/zoneinfo",
 "/etc/zoneinfo")

$ PYTHONTZPATH="/etc/zoneinfo:/usr/share/zoneinfo" python print_tzpath.py
("/etc/zoneinfo",
 "/usr/share/zoneinfo")

$ PYTHONTZPATH="" python print_tzpath.py
()

这不提供内置机制来在默认搜索路径之前或之后添加路径,因为这些用例可能更小众。应该可以相当容易地使用默认搜索路径填充环境变量

$ export DEFAULT_TZPATH=$(python -c \
    "import os, zoneinfo; print(os.pathsep.join(zoneinfo.TZPATH))")

reset_tzpath 函数

zoneinfo 提供了一个 reset_tzpath 函数,允许在运行时更改搜索路径。

def reset_tzpath(
    to: Optional[Sequence[Union[str, os.PathLike]]] = None
) -> None:
    ...

当使用一系列路径调用时,此函数将 zoneinfo.TZPATH 设置为从所需值构造的元组。当不带参数或使用 None 调用时,此函数将 zoneinfo.TZPATH 重置为默认配置。

这可能主要用于(永久或临时)禁用系统时区路径的使用并强制模块使用 tzdata 包。改变 reset_tzpath 的操作可能不常见,除了在对时区配置敏感的测试函数中,但提供一种官方机制来改变它似乎比允许围绕 TZPATH 不可变性而产生大量黑客行为更好。

注意

尽管在运行时更改 TZPATH 是受支持的操作,但应告知用户这样做有时可能导致不寻常的语义,并且在进行设计权衡时,将更多地考虑使用静态 TZPATH,这是更常见的用例。

构造函数所述,主 ZoneInfo 构造函数采用缓存以确保两个构造相同的 ZoneInfo 对象始终被比较为相同(即 ZoneInfo(key) is ZoneInfo(key)),并且此缓存的性质是实现定义的。这意味着在某些情况下,当 ZoneInfo 构造函数与相同 key 在不同 TZPATH 值下使用时,其行为可能不可预测地不一致。例如

>>> reset_tzpath(to=["/my/custom/tzdb"])
>>> a = ZoneInfo("My/Custom/Zone")
>>> reset_tzpath()
>>> b = ZoneInfo("My/Custom/Zone")
>>> del a
>>> del b
>>> c = ZoneInfo("My/Custom/Zone")

在此示例中,My/Custom/Zone 仅存在于 /my/custom/tzdb 中,而不存在于默认搜索路径中。在所有实现中,a 的构造函数必须成功。 b 的构造函数是否成功是实现定义的,但如果成功,则 a is b 必须为真,因为 ab 都是对同一键的引用。c 的构造函数是否成功也是实现定义的。 zoneinfo 的实现 可以 返回在先前构造函数调用中构造的对象,或者它们可能因异常而失败。

向后兼容性

由于它将创建一个新的 API,因此不会有向后兼容性问题。

只需进行少量修改,即可创建一个支持 Python 3.6+ 的 zoneinfo 模块的向后移植。

tzdata 包被设计为“纯数据”,并且应该支持它能构建的任何 Python 版本(包括 Python 2.7)。

安全隐患

这将需要从磁盘解析 zoneinfo 数据,主要来自系统位置,但也可能来自用户提供的数据。实现中的错误(特别是 C 代码)可能导致潜在的安全问题,但相对于解析其他文件类型没有特殊风险。

由于时区数据键本质上是相对于某个时区根的路径,因此实现应注意避免路径遍历攻击。请求诸如 ../../../path/to/something 之类的键不应泄露文件系统除时区路径之外的任何信息。

参考实现

初始参考实现可在 https://github.com/pganssle/zoneinfo 获取

这最终可能会被转换为 3.6+ 的向后移植。

被拒绝的想法

构建自定义 tzdb 编译器

使用 TZif 格式的一个主要问题是,它实际上没有包含足够的信息来始终正确确定 tzinfo.dst() 返回的值。这是因为对于任何给定的时区偏移量,TZif 只标记了 UTC 偏移量以及它是否表示 DST 偏移量,但 tzinfo.dst() 返回 DST 偏移的总量,以便可以从 datetime.utcoffset() - datetime.dst() 重构“标准”偏移量。用于 dst() 的值可以通过查找等效的 STD 偏移量并计算差异来确定,但 TZif 格式没有指定哪些偏移量构成 STD/DST 对,因此必须使用启发式方法来确定这一点。

一个常见的启发式方法——查看最近的标准偏移量——在 1992 年和 1996 年葡萄牙时区变化的情况下明显失败,当时“标准”偏移量在 DST 转换期间移动了 1 小时,导致从 STD 转换为 DST 状态而偏移量没有变化。事实上,时区有可能(尽管从未发生过)被创建为永久 DST 且没有标准偏移量。

尽管这些信息在编译后的 TZif 二进制文件中缺失,但它存在于原始 tzdb 文件中,并且可以自行解析这些信息并创建更合适的二进制格式。

这个想法被拒绝了,原因如下:

  1. 它排除了使用任何系统部署的时区信息,这些信息通常只以 TZif 格式存在。
  2. 原始 tzdb 格式虽然稳定,但比 TZif 格式 更不 稳定;一些下游 tzdb 解析器已经遇到过旧版本自定义解析器与最新 tzdb 版本不兼容的问题,导致创建了“后卫”格式以简化过渡。[8]
  3. 目前启发式算法足以在 dateutilpytz 中处理所有已知时区,无论是历史的还是当前的,而且不太可能出现无法通过启发式算法捕获的新时区——尽管出现无法被 当前 一代启发式算法捕获的新规则的可能性更大;在这种情况下,需要错误修复来适应变化的情况。
  4. 从一开始,dst() 方法的实用性(实际上 TZif 中的 isdst 参数)就有些可疑,因为几乎所有有用的信息都包含在 utcoffset()tzname() 方法中,这些方法不存在相同的问题。

简而言之,维护自定义 tzdb 编译器或编译包增加了 CPython 开发团队和系统管理员的维护负担,其主要好处是解决一个假设的失败,而这种失败即使发生,在现实世界中也可能影响微乎其微。

默认将 tzdata 包含在标准库中

虽然引入了 ensurepip 机制到 CPython 的 PEP 453 为 PyPI 上维护的标准库模块提供了方便的模板,但一个潜在类似的 ensuretzdata 机制必要性较低,而且会足够复杂,因此超出了本 PEP 的范围。

由于 zoneinfo 模块旨在尽可能使用系统时区数据,因此在部署时区数据的系统上,tzdata 包是不必要的(并且可能是不受欢迎的),因此将 tzdata 与 CPython 一起发布似乎不是关键。

目前尚不清楚这些混合标准库/PyPI 模块应如何更新(除了 pip,它具有自然的更新和通知机制),并且由于它对模块的操作并不关键,因此似乎明智地推迟任何此类提案。

支持闰秒

除了时区偏移量和名称规则,IANA 时区数据库还提供了闰秒数据源。这被认为超出了范围,因为 datetime.datetime 目前不支持闰秒,并且闰秒数据问题可以推迟到添加闰秒支持之后。

即使 zoneinfo 模块不使用闰秒数据,第一方 tzdata 包也应该附带闰秒数据。

使用 pytz 类似的接口

PEP 431 曾提议使用类似 pytz[18])的接口,但最终因缺乏对模糊 datetime 的支持而被撤回/拒绝。PEP 495 添加了 fold 属性来解决这个问题,但 fold 使得 pytz 的非标准 tzinfo 类不再必要,因此不再需要类似 pytz 的接口。[2]

zoneinfo 方法更接近于 dateutil.tz,后者在 Python 3.6 发布前不久实现了对 fold 的支持(包括对旧版本的向后移植)。

通过 Microsoft 的 ICU API 支持 Windows

Windows 不以 TZif 文件的形式提供时区数据库,但自 Windows 10 的 2017 创意者更新以来,微软提供了与国际组件 for Unicode (ICU) 项目 [13] [14] 交互的 API,其中包括访问时区数据(源自 IANA 时区数据库)的 API。[15]

提供此绑定的话,我们就可以“开箱即用”地支持 Windows,而无需安装 tzdata 包,但遗憾的是,Windows 提供的 C 头文件无法访问底层时区数据——只提供了查询系统以获取转换和偏移信息的 API。这将以可能与非 ICU 实现不兼容的方式限制任何基于 ICU 的实现的语义——尤其是在缓存行为方面。

由于 ICU 似乎不能简单地用作 ZoneInfo 对象的额外数据源,本 PEP 将 ICU 支持视为超出范围,并且可能由第三方库更好地支持。

替代环境变量配置

本 PEP 提议使用一个环境变量:PYTHONTZPATH。这基于以下假设:大多数希望操作时区路径的用户会希望完全替换它(例如,“我确切知道我的时区数据在哪里”),而其他用例,如在现有搜索路径前添加路径,则不那么常见。

还有其他几种被考虑并拒绝的方案

  1. PYTHON_TZPATH 分为两个环境变量:DEFAULT_PYTHONTZPATHPYTHONTZPATH,其中 PYTHONTZPATH 将包含要附加(或前置)到默认时区路径的值,而 DEFAULT_PYTHONTZPATH替换 默认时区路径。这被拒绝,因为如果主要用例是替换而不是增加,这可能会导致用户混淆。
  2. 添加 PYTHONTZPATH_PREPENDPYTHONTZPATH_APPEND 或两者,以便用户可以在不尝试确定默认时区路径的情况下在两端增加搜索路径。这被拒绝,因为它可能是不必要的,并且如果对此功能有很大需求,可以很容易地在未来的更新中以向后兼容的方式添加。
  3. 只使用 PYTHONTZPATH 变量,但提供一个自定义特殊值来表示默认时区路径,例如 <<DEFAULT_TZPATH>>,这样用户就可以通过例如 PYTHONTZPATH=<<DEFAULT_TZPATH>>:/my/path/my/path 附加到时区路径的末尾。

    这种方案的一个优点是,它将为指定搜索路径上非基于文件的元素添加一个自然的扩展点,例如更改 tzdata 的优先级(如果存在),或者将来如果将 TZDIST 的原生支持添加到库中。

    这被拒绝的主要原因是,此类特殊值通常不会出现在类 PATH 变量中,并且目前提出的唯一用例是默认 TZPATH 的替代品,可以通过执行 Python 程序查询默认值来获取。拒绝此方案的另一个因素是,由于 PYTHONTZPATH 只接受绝对路径,任何不表示有效绝对路径的字符串都被隐式保留供将来使用,因此可以在库的未来版本中以向后兼容的方式根据需要引入这些特殊值。

使用 datetime 模块

一个可能的想法是将 ZoneInfo 添加到 datetime 模块中,而不是为其提供单独的模块。本 PEP 倾向于使用单独的 zoneinfo 模块,尽管也曾考虑过嵌套的 datetime.zoneinfo 模块。

反对将 ZoneInfo 直接放入 datetime 的论点

datetime 模块已经有些拥挤,因为它有许多行为复杂的类——datetime.datetimedatetime.datedatetime.timedatetime.timedeltadatetime.timezonedatetime.tzinfo。该模块的实现和文档已经相当复杂,如果能避免加剧问题,最好不要这样做。

ZoneInfo 类在某些方面也不同于 datetime 提供的所有其他类;其他类都旨在成为精简、简单的数据类型,而 ZoneInfo 类更复杂:它是一个特定格式 (TZif) 的解析器,是该格式中存储信息的表示,以及在系统中已知位置查找信息的机制。

最后,虽然需要 zoneinfo 模块的人也需要 datetime 模块,但反之则不一定:许多人希望在没有 zoneinfo 的情况下使用 datetime。考虑到 zoneinfo 可能会引入额外的、可能更重量级的标准库模块,最好允许两者分开导入——特别是如果 Python 的未来有可能出现“tree shaking”分发。[9]

最终分析表明,将 zoneinfo 保持为一个独立的模块,并拥有独立的文档页面,而不是将其类和函数直接放入 datetime 中,是最好的选择。

使用 datetime.zoneinfo 而不是 zoneinfo

更可接受的配置可能是将 zoneinfo 作为 datetime 下的一个模块进行嵌套,即 datetime.zoneinfo

支持此观点的论点

  1. 它巧妙地将 zoneinfodatetime 命名空间关联起来
  2. timezone 类已经在 datetime 中,而一些时区在 datetime 中,另一些在顶级模块中,这可能看起来很奇怪。
  3. 如前所述,导入 zoneinfo 必然需要导入 datetime,因此要求导入父模块并非强加。

反对这个的论点

  1. 为了避免强制所有 datetime 用户导入 zoneinfozoneinfo 模块需要进行惰性导入,这意味着最终用户需要明确导入 datetime.zoneinfo(而不是导入 datetime 并访问模块上的 zoneinfo 属性)。这是 dateutil 的工作方式(所有子模块都是惰性导入的),这对于最终用户来说是一个长期存在的困惑来源。

    可以通过使用模块级别的 __getattr____dir__ (遵循 PEP 562) 来避免最终用户这种令人困惑的要求,但这会增加 datetime 模块实现的复杂性。模块或类中的这种行为往往会混淆静态分析工具,这对于像 datetime 这样广泛使用和关键的库来说可能不是理想的。

  2. 将实现嵌套在 datetime 下可能需要将 datetime 从单个文件模块 (datetime.py) 重组为带有 __init__.py 的目录。这是一个次要问题,但 datetime 模块的结构已经稳定多年,如果可能的话,最好避免改动。

    通过将 zoneinfo 实现为 _zoneinfo.py 并在 datetime 内部将其导入为 zoneinfo可以 缓解这种担忧,但这从美学或代码组织的角度来看似乎不可取,并且会排除最终用户需要显式导入 datetime.zoneinfo 的嵌套版本。

本 PEP 认为,总体而言,最好使用一个单独的顶级 zoneinfo 模块,因为嵌套的好处并没有大到足以压倒实际的实现问题。

脚注

[a]
“绝大多数用户只想要少数几种时区”这一说法是基于经验印象而非科学依据。作为数据点之一,dateutil 提供了多种时区类型,但用户支持主要集中在这三种类型上。
[b]
如果用户故意清除时区缓存,则“相同构造的 ZoneInfo 对象应该相同”的说法可能会被违反。
[c]
给定 datetime 的哈希值在第一次计算时会被缓存,所以我们不需要担心给定 datetime 对象的哈希值在其生命周期内会改变这个可能更严重的问题。
[d] (1, 2, 3)
这里的“第一方”与“第三方”的区别在于,尽管它通过 PyPI 分发且目前不默认包含在 Python 中,但它应被视为 CPython 的官方子项目,而非“受认可的”第三方包。

参考资料

其他时区实现


来源:https://github.com/python/peps/blob/main/peps/pep-0615.rst

最后修改:2025-02-01 07:28:42 GMT