PEP 723 – 内联脚本元数据
- 作者:
- Ofek Lev <ofekmeister at gmail.com>
- 赞助人:
- Adam Turner <python at quite.org.uk>
- PEP 代表:
- Brett Cannon <brett at python.org>
- 讨论邮件列表:
- Discourse 帖子
- 状态:
- 最终
- 类型:
- 标准跟踪
- 主题:
- 打包
- 创建日期:
- 2023 年 8 月 4 日
- 发布历史:
- 2023 年 8 月 4 日, 2023 年 8 月 6 日, 2023 年 8 月 23 日, 2023 年 12 月 6 日
- 替换:
- 722
- 决议:
- Discourse 消息
摘要
本 PEP 指定了一种元数据格式,该格式可以嵌入到单个 Python 脚本中,以协助可能需要与这些脚本交互的启动器、IDE 和其他外部工具。
动机
Python 通常用作脚本语言,Python 脚本作为 shell 脚本、批处理文件等的(更好)替代方案。当 Python 代码被构建为脚本时,它通常存储为单个文件,并且不期望可以使用任何其他可用于导入的本地代码。因此,可以通过任意基于文本的方式(例如电子邮件、指向脚本的 URL 或甚至聊天窗口)与他人共享。这样构建的代码可能永远作为单个文件存在,永远不会成为具有自身目录和 pyproject.toml
文件的完整项目。
用户使用这种方法时遇到的一个问题是,没有标准机制来为负责执行此类脚本的工具定义元数据。例如,运行脚本的工具可能需要知道需要哪些依赖项或支持的 Python 版本。
目前没有解决此问题的标准工具,并且此 PEP 并不试图定义一个。但是,任何确实解决此问题的工具都需要知道脚本的运行时需求。通过定义存储此类元数据的标准格式,现有工具以及任何未来的工具都将能够获取该信息,而无需用户在脚本中包含特定于工具的元数据。
基本原理
此 PEP 定义了一种将元数据嵌入到脚本本身中的机制,而不是在外部文件中。
元数据格式旨在类似于 Python 项目目录中 pyproject.toml
文件中数据布局,为具有 Python 项目编写经验的用户提供熟悉体验。通过使用类似的格式,我们避免了打包工具之间不必要的差异,这是用户在最近的 打包调查 中表达的常见挫败感。
以下是一些此 PEP 希望支持的用例
- 面向用户的 CLI,能够执行脚本。如果以 Hatch 为例,界面将非常简单
hatch run /path/to/script.py [args]
,Hatch 将管理该脚本的环境。此类工具可以用作非 Windows 系统上的 shebang 行,例如#!/usr/bin/env hatch run
。 - 希望转换为目录类型项目的脚本。用户可能在本地或远程 REPL 环境中快速进行原型设计,然后如果他们的想法成功,则决定转换为更正式的项目布局。能够在脚本中定义依赖项对于拥有完全可重现的错误报告非常有用。
- 希望避免手动依赖项管理的用户。例如,具有添加/删除依赖项命令的包管理器,或在 CI 中的依赖项更新自动化,根据新版本或响应 CVE 触发 [1]。
规范
此 PEP 定义了一种元数据注释块格式,其灵感 [2] 松散地来自 reStructuredText 指令。
任何 Python 脚本都可以拥有顶级注释块,这些块必须以行 # /// TYPE
开头,其中 TYPE
确定如何处理内容。即:单个 #
,后跟一个空格,后跟三个正斜杠,后跟一个空格,后跟元数据类型。块必须以行 # ///
结尾。即:单个 #
,后跟一个空格,后跟三个正斜杠。TYPE
必须仅包含 ASCII 字母、数字和连字符。
这两行(# /// TYPE
和 # ///
)之间的每一行都必须是以下注释开头 #
。如果 #
后面有字符,则第一个字符必须是空格。嵌入式内容通过在第二字符是空格时删除每行的前两个字符,否则仅删除第一个字符(这意味着该行仅包含单个 #
)来形成。
当下一行不是上面描述的有效嵌入式内容行时,将给出结束行 # ///
的优先级。例如,以下是单个完全有效的块
# /// some-toml
# embedded-csharp = """
# /// <summary>
# /// text
# ///
# /// </summary>
# public class MyClass { }
# """
# ///
开始行不得放置在另一开始行及其结束行之间。在这种情况下,工具可能会产生错误。未关闭的块必须被忽略。
当定义了多个相同 TYPE
的注释块时,工具必须产生错误。
读取嵌入式元数据的工具可能会尊重标准 Python 编码声明。如果它们选择不这样做,则必须将文件处理为 UTF-8。
这是可以用于解析元数据的规范正则表达式
(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$
在文本规范和正则表达式之间存在差异的情况下,文本规范优先。
工具不得读取类型未由本 PEP 或未来的 PEP 标准化的元数据块。
脚本类型
第一种元数据块类型名为 script
,其中包含脚本元数据(依赖项数据和工具配置)。
此文档可能包含顶级字段 dependencies
和 requires-python
,并且可以选择包含 [tool]
表格。
任何工具(脚本运行器或其他工具)都可以使用 [tool]
表格来配置行为。它具有与 pyproject.toml
中的 工具表格 相同的语义。
顶级字段为
dependencies
:一个字符串列表,指定脚本的运行时依赖项。每个条目都必须是有效的 PEP 508 依赖项。requires-python
:一个字符串,指定脚本与其兼容的 Python 版本。此字段的值必须是有效的 版本说明符。
如果无法提供指定的 dependencies
,脚本运行器必须报错。如果无法提供满足指定 requires-python
的任何 Python 版本,脚本运行器应该报错。
示例
以下是一个包含嵌入式元数据的脚本示例
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "requests<3",
# "rich",
# ]
# ///
import requests
from rich.pretty import pprint
resp = requests.get("https://peps.pythonlang.cn/api/peps.json")
data = resp.json()
pprint([(k, v["title"]) for k, v in data.items()][:10])
参考实现
以下是如何在 Python 3.11 或更高版本上读取元数据的示例。
import re
import tomllib
REGEX = r'(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$'
def read(script: str) -> dict | None:
name = 'script'
matches = list(
filter(lambda m: m.group('type') == name, re.finditer(REGEX, script))
)
if len(matches) > 1:
raise ValueError(f'Multiple {name} blocks found')
elif len(matches) == 1:
content = ''.join(
line[2:] if line.startswith('# ') else line[1:]
for line in matches[0].group('content').splitlines(keepends=True)
)
return tomllib.loads(content)
else:
return None
通常,工具会编辑依赖项,例如 CI 中的包管理器或依赖项更新自动化。以下是使用 tomlkit
库 修改内容的粗略示例。
import re
import tomlkit
REGEX = r'(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$'
def add(script: str, dependency: str) -> str:
match = re.search(REGEX, script)
content = ''.join(
line[2:] if line.startswith('# ') else line[1:]
for line in match.group('content').splitlines(keepends=True)
)
config = tomlkit.parse(content)
config['dependencies'].append(dependency)
new_content = ''.join(
f'# {line}' if line.strip() else f'#{line}'
for line in tomlkit.dumps(config).splitlines(keepends=True)
)
start, end = match.span('content')
return script[:start] + new_content + script[end:]
请注意,此示例使用了保留 TOML 格式的库。这不是编辑的任何要求,而是一个“很好有”的功能。
以下是如何读取任意元数据块流的示例。
import re
from typing import Iterator
REGEX = r'(?m)^# /// (?P<type>[a-zA-Z0-9-]+)$\s(?P<content>(^#(| .*)$\s)+)^# ///$'
def stream(script: str) -> Iterator[tuple[str, str]]:
for match in re.finditer(REGEX, script):
yield match.group('type'), ''.join(
line[2:] if line.startswith('# ') else line[1:]
for line in match.group('content').splitlines(keepends=True)
)
向后兼容性
在撰写本文时,# /// script
块注释起始符在任何 Python 文件中 GitHub 上 均未出现。因此,此 PEP 几乎不会导致现有脚本出现问题。
安全影响
如果使用自动安装依赖项的工具运行包含嵌入式元数据的脚本,则可能会导致将任意代码下载并安装到用户的环境中。
此处的风险是用于运行脚本的工具功能的一部分,因此应该由工具本身解决。此 PEP 引入的唯一额外风险是,如果运行包含嵌入元数据的不可信脚本,可能会安装潜在的恶意依赖项或传递依赖项。
此风险可以通过在运行代码之前审查代码的正常良好实践来解决。此外,工具可能能够提供锁定功能以减轻此风险。
如何教授
要在脚本中嵌入元数据,请定义一个以# /// script
行开头并以# ///
行结尾的注释块。这两行之间的每一行都必须是注释,并且完整内容是通过删除前两个字符得到的。
# /// script
# dependencies = [
# "requests<3",
# "rich",
# ]
# requires-python = ">=3.11"
# ///
允许的字段在以下表格中描述
字段 | 描述 | 工具行为 |
依赖项 |
一个字符串列表,指定脚本的运行时依赖项。每个条目都必须是有效的PEP 508 依赖项。 | 如果无法提供指定的依赖项,工具将报错。 |
requires-python |
一个字符串,指定脚本兼容的 Python 版本。此字段的值必须是有效的版本说明符。 | 如果无法执行满足约束的任何 Python 版本,工具可能会报错。 |
此外,允许使用[tool]
表格。允许的内容的详细信息类似于pyproject.toml
中允许的内容,但必须在相关工具的文档中包含精确信息。
各个工具是否根据嵌入的元数据更改其行为取决于工具本身。例如,并非每个脚本运行器都能够为requires-python
字段定义的特定 Python 版本提供环境。
工具表格可由任何工具(脚本运行器或其他工具)用于配置行为。
建议
支持管理不同 Python 版本的工具应尝试使用与脚本的requires-python
元数据兼容的最高可用 Python 版本(如果已定义)。
工具支持
以下是已表示支持此 PEP 或承诺在接受后实施支持的工具列表
- Pantsbuild 和 Pex:表示支持任何定义依赖项的方式,以及此 PEP 认为有效用例的功能,例如从脚本构建包和嵌入工具配置
- Mypy 和 Ruff:强烈表示支持嵌入工具配置,因为它可以解决用户现有的痛点
- Hatch:(此 PEP 的作者)表示支持此 PEP 的所有方面,并将成为首批支持使用特定配置的 Python 版本运行脚本的工具之一
被拒绝的想法
为什么不使用类似 requirements.txt 的注释块?
此 PEP 认为,对于 Python 代码作为单个文件脚本存在的用户,存在不同类型的用户
- 仅将 Python 作为脚本语言来完成特定任务的非程序员。这些用户不太可能熟悉操作系统的概念,例如 Shebang 行或
PATH
环境变量。一些例子- 普通用户,可能是在工作场所,希望编写脚本来自动执行某些操作以提高效率或减少乏味工作
- 在行业或学术界从事数据科学或机器学习的人,希望编写脚本分析某些数据或用于研究目的。这些用户很特殊,因为尽管他们拥有有限的编程知识,但他们从 StackOverflow 和具有编程倾向的博客等来源学习,并且越来越有可能成为共享知识和代码的社区的一部分。因此,相当一部分这些用户将对 Git(Hub)、Jupyter、HuggingFace 等有所了解。
- 管理操作系统的非程序员,例如系统管理员。这些用户能够设置
PATH
等,但不太可能熟悉 Python 概念,例如虚拟环境。这些用户通常独立操作,并且对接触用于共享的工具(如 Git)的需求有限。 - 编写脚本主要用于自身用途的程序员。这些用户随着时间的推移会在各种语言中积累大量脚本,他们使用这些脚本来自动化工作流程,并且通常将它们存储在一个目录中,该目录可能被版本控制以实现持久性。非 Windows 用户可能会为每个 Python 脚本设置一个 Shebang 行,指向所需的 Python 可执行文件或脚本运行器。
- 管理操作系统/基础设施的程序员,例如 SRE。这些用户不太可能熟悉 Python 概念,例如虚拟环境,但可能熟悉 Git,并且最常使用它来版本控制管理基础设施(如 Python 脚本和 Kubernetes 配置)所需的一切。
此 PEP 认为,提议的基于 TOML 的元数据格式对于每个用户类别都是最佳的,并且类似于需求的块注释仅适用于那些熟悉requirements.txt
的人,而这仅代表一小部分用户。
- 对于自动执行任务的普通用户或数据科学家,他们一开始就没有任何上下文,并且不太可能熟悉 TOML 或
requirements.txt
。这些用户很可能依赖通过搜索引擎在线找到的代码片段,或利用 AI(以聊天机器人或直接代码完成软件的形式)。与存储在pyproject.toml
中的依赖项信息相似,将相对快速地提供有用的搜索结果,并且虽然pyproject.toml
格式和脚本元数据格式并不相同,但任何由此产生的差异对于目标用户来说都不太难解决。此外,这些用户最容易受到格式怪癖和语法错误的影响。TOML 是一种定义明确的格式,具有现有的在线验证器,其功能包括与 Python 表达式兼容的赋值,并且没有严格的缩进规则。另一方面,块注释格式很容易因忘记冒号等而格式错误,并且使用搜索引擎调试其原因对于此类用户来说将是一项艰巨的任务。
- 对于系统管理员类型,他们与前面描述的用户一样,不太可能熟悉 TOML 或
requirements.txt
。对于任何一种格式,他们都必须阅读文档。他们可能更喜欢 TOML,因为他们习惯于结构化数据格式,并且他们的系统中感知到的“魔法”更少。此外,为了维护他们的系统,从 shell 中搜索
/// script
比搜索可能随着时间的推移而具有众多扩展的块注释要容易得多。 - 对于 SRE 类型,他们可能已经从他们可能需要使用的其他项目中熟悉了 TOML,例如配置GitLab Runner或Cloud Native Buildpacks。
这些用户负责其系统的安全性,并且很可能已设置了安全扫描程序来自动打开 PR 以更新依赖项的版本。像 Dependabot 这样的自动化工具更容易使用现有的 TOML 库,而不是为块注释格式编写自己的自定义解析器。
- 对于程序员类型,他们更有可能熟悉 TOML,而不是他们曾经见过的
requirements.txt
文件,除非他们是有过编写应用程序经验的 Python 程序员。如果熟悉需求格式,则必然意味着他们至少对生态系统有所了解,因此可以安全地假设他们知道 TOML 是什么。此 PEP 对这些用户的另一个好处是,他们的 IDE(如 Visual Studio Code)能够比为该功能编写自定义逻辑更容易地提供 TOML 语法高亮显示。
此外,由于最初的块注释替代格式(双#
)违反了PEP 8的建议,因此尊重该建议的 lint 工具和 IDE 自动格式化程序将默认失败,最终提案使用以单个#
字符开头的标准注释,没有任何明显的开始或结束序列。
对于 Python 用户来说,常规注释(似乎并非旨在供机器使用,即编码声明)影响行为的概念并不常见,并且直接违反了“显式优于隐式”的基本原则。
用户键入对他们来说看起来像散文的内容可能会改变运行时行为。此 PEP 认为,即使已为此类工具设置了此类功能(可能由系统管理员设置),发生这种情况的可能性对用户也不友好。
最后,也是至关重要的是,此 PEP 的替代方案(如PEP 722)不能满足本文列出的用例,例如设置支持的 Python 版本、最终将脚本构建为包以及使机器代表用户编辑元数据的能力。这些功能请求很可能仍然存在,并且可以想象未来另一个 PEP 将允许嵌入此类元数据。届时将有多种方法可以实现相同的功能,这违反了我们“应该有一种——最好只有一种——显而易见的方法来做到这一点”的基本原则。
为什么不使用多行字符串?
此 PEP 的先前版本建议将元数据存储如下
__pyproject__ = """
...
"""
此提案最显著的问题在于,嵌入的 TOML 将受到以下方面的限制
- 由于这会与包含文档的 Python 字符串冲突,因此无法在 TOML 中使用多行双引号字符串。许多 TOML 编写器不会保留样式,并且可能会产生格式错误的输出。
- Python 字符串中的字符转义方式与 TOML 字符串中的字符转义方式略有不同。可以通过强制使用原始字符串来保留一对一的字符映射,但这
r
前缀要求可能会让用户感到困惑。
为什么不重用核心元数据字段?
此 PEP 的先前版本建议重用现有的 元数据标准,该标准用于描述项目。
此提案存在两个重大问题
name
和version
字段是必需的,更改它们需要单独的 PEP- 重用数据 从根本上来说是滥用它
为什么不限制为特定的元数据字段?
通过将元数据限制为仅 dependencies
,我们将阻止支持管理 Python 安装的工具的已知用例,这将允许用户为新语法或标准库功能定位 Python 的特定版本。
为什么不限制工具配置?
通过不允许 [tool]
表,我们将阻止对用户有益的已知功能。例如
- 脚本运行器可能支持为嵌入式锁文件注入依赖项解析数据(这是 Go 的
gorun
可以做到的)。 - 脚本运行器可能支持配置指令,以在以下情况下在容器中运行脚本:依赖项没有跨平台支持,或者设置对于普通用户来说过于复杂,例如需要 Nvidia 驱动程序。此类情况将允许用户继续执行他们想要执行的操作,否则他们可能会完全停止。
- 工具可能希望试验一些功能,以减轻用户的开发负担,例如将单个文件脚本构建到包中。我们收到了 反馈,说明在野外已经存在一些工具可以从单个文件中构建轮子和源代码分发版。
Rust RFC 的嵌入元数据作者 告诉我们,他们也正在积极研究这个问题,因为用户的反馈表明,管理小型项目存在不必要的摩擦。
至少有一个主要的构建系统 承诺 支持这一点。
为什么不限制工具行为?
此 PEP 的先前版本建议,非脚本运行工具在脚本不是工具的唯一输入时 SHOULD NOT 修改其行为。例如,如果 linter 使用目录路径调用,它 SHOULD 的行为应与零文件嵌入元数据时相同。
这样做是为了预防工具行为混淆并生成各种工具功能请求以支持此 PEP。但是,在讨论过程中,我们收到了 反馈,来自工具维护人员的反馈表明这将是不希望的,并且可能对用户造成混淆。此外,这可能允许在某些情况下以普遍更简单的方式配置工具并解决现有问题。
为什么不只是使用带有 pyproject.toml
的 Python 项目?
同样,这里的一个关键问题是,此提案的目标受众是编写不打算分发的脚本的人员。有时脚本会被“共享”,但这比“分发”要非正式得多——它通常涉及通过电子邮件发送脚本以及一些关于如何运行它的书面说明,或者将指向 GitHub gist 的链接传递给某人。
期望此类用户学习 Python 打包的复杂性是一个巨大的复杂性提升,并且几乎肯定会让人觉得“Python 对脚本来说太难了”。
此外,如果这里的期望是 pyproject.toml
将以某种方式设计用于就地运行脚本,那么这是标准中目前不存在的新功能。至少,在 关于在 Discourse 上使用 pyproject.toml
用于不会作为轮子分发的项目的当前讨论 解决之前,这不是一个合理的建议。即使这样,它也没有解决“通过 gist 或电子邮件向某人发送脚本”的用例。
为什么不从 import 语句推断需求?
这个想法是自动识别源文件中的 import
语句,并将它们转换为需求列表。
但是,由于以下几个原因,这是不可行的。首先,上面关于需要保持语法易于解析、适用于所有 Python 版本以及由其他语言编写的工具解析的要点同样适用于此处。
其次,符合简单存储库 API 的 PyPI 和其他包存储库没有提供从导入的模块名称解析包名称的机制(另请参阅 此相关讨论)。
第三,即使存储库提供了此信息,相同的导入名称也可能对应于 PyPI 上的多个包。有人可能会反对说,只有在有多个项目提供相同的导入名称时才需要消除歧义。但是,这将使任何人都很容易无意中或恶意地破坏工作的脚本,方法是将包上传到 PyPI,该包提供的导入名称与现有项目的导入名称相同。另一种方法是在候选中选择第一个在索引中注册的包,这在开发具有与现有模糊包相同的导入名称的流行包时会令人困惑,如果现有包是故意上传具有足够通用导入名称的恶意软件,则甚至会造成危害,该恶意软件具有很高的重复使用概率。
一个相关的想法是将需求作为注释附加到导入语句而不是将它们收集在一个块中,使用如下语法
import numpy as np # requires: numpy
import rich # requires: rich
这仍然存在解析困难。此外,在多行导入的情况下,注释放置在哪里是不明确的,并且可能看起来很丑陋
from PyQt5.QtWidgets import (
QCheckBox, QComboBox, QDialog, QDialogButtonBox,
QGridLayout, QLabel, QSpinBox, QTextEdit
) # requires: PyQt5
此外,此语法在所有情况下都不能按预期直观地运行。考虑一下
import platform
if platform.system() == "Windows":
import pywin32 # requires: pywin32
这里,用户的意图是该包仅在 Windows 上需要,但脚本运行器无法理解这一点(正确的方法是写 requires: pywin32 ; sys_platform == 'win32'
)。
(感谢 Jean Abou-Samra 对此点的清晰讨论)
为什么不使用需求文件来指定依赖项?
将需求放在需求文件中,不需要 PEP。你现在就可以做到这一点,事实上,许多临时解决方案很可能都这么做了。但是,如果没有标准,就无法知道如何找到脚本的依赖项数据。此外,需求文件格式是特定于 pip 的,因此依赖于它的工具依赖于 pip 的实现细节。
因此,为了制定标准,需要两件事
- 需求文件格式的标准化替换。
- 用于查找给定脚本的需求文件的标准。
第一项是一项重大的工作。它已经被讨论过很多次了,但到目前为止还没有人尝试真正去做。最有可能的方法是为目前使用需求文件解决的各个用例开发标准。这里的一个选项是,此 PEP 可以简单地定义一个新的文件格式,该格式只是一个包含 PEP 508 需求的文本文件,每行一个。这只会留下如何找到该文件的问题。
这里“显而易见”的解决方案是做一些类似的事情,例如将文件命名为与脚本相同,但扩展名为 .reqs
(或类似的东西)。但是,这仍然需要 _两个_ 文件,而目前只需要一个文件,因此,它不符合“更好的批处理文件”模型(shell 脚本和批处理文件通常是自包含的)。它要求开发人员记住将这两个文件放在一起,这可能并不总是可能的。例如,系统管理策略可能要求某个目录中的_所有_文件都可执行(例如,Linux 文件系统标准要求 /usr/bin
可执行)。并且某些共享脚本的方法(例如,将其发布在诸如 Github 的 gist 或公司内部网之类的文本文件共享服务上)可能不允许从脚本的位置派生关联需求文件的位置(像 pipx
这样的工具支持直接从 URL 运行脚本,因此“下载并解压缩脚本及其依赖项的 zip 文件”可能不是一个适当的要求)。
从本质上讲,这里的问题是存在一个明确说明的要求,即该格式支持_在脚本文件中本身_存储依赖项数据。不这样做的方法只是忽略了该要求。
为什么不使用(可能受限的)Python 语法?
这通常涉及将元数据存储为多个特殊变量,例如以下变量。
__requires_python__ = ">=3.11"
__dependencies__ = [
"requests",
"click",
]
此提案最重要的一个问题是,它要求所有依赖项数据的使用者都实现一个 Python 解析器。即使语法受到限制,_其余_ 脚本也将使用完整的 Python 语法,并且尝试定义一个可以成功地与周围代码隔离解析的语法很可能极其困难且容易出错。
此外,Python 的语法在每个版本中都会发生变化。如果提取依赖项数据需要 Python 解析器,则解析器需要知道脚本是用哪个版本的 Python 编写的,并且通用工具需要一个可以处理_多个_ Python 版本的解析器,其开销是不可持续的。
使用这种方法,随着新的扩展被添加,脚本可能会被许多变量弄乱。此外,直观地理解哪些元数据字段对应于哪些变量名称会让用户感到困惑。
不过,值得注意的是,pip-run
实用程序确实实现了(此方法的扩展形式)。关于 pip-run
设计的进一步讨论可在该项目的 issue tracker 上找到。
本地依赖项怎么办?
这些可以在不需要特殊元数据和工具的情况下处理,只需将依赖项的位置添加到 sys.path
中即可。此 PEP 对这种情况根本不需要。另一方面,如果“本地依赖项”是实际发布在本地处的分发版,则可以使用 PEP 508 需求指定它们,并在使用工具的 UI 运行工具时指定本地包索引。
未解决的问题
目前没有。
脚注
版权
本文档置于公共领域或根据 CC0-1.0-Universal 许可证,以更宽松的许可证为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0723.rst